From b288533eb12a935d9ac33b1657a4707df335d9b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:10:18 +0000 Subject: [PATCH 01/13] feat!: rebuild Ogiri as a focused Java PostgreSQL session library at 0.1.0 --- .claude/settings.json | 38 - .coderabbit.yaml | 15 - .editorconfig | 12 +- .gitattributes | 21 - .github/dependabot.yml | 35 - .gitignore | 67 +- .java-version | 1 - .jitpack.yml | 20 - .ogiri-version | 1 - .sdkmanrc | 3 - CHANGELOG.md | 175 - CODE_OF_CONDUCT.md | 98 - CONTEXT.md | 49 - CONTRIBUTING.md | 125 +- PUBLISHING.md | 183 - README.md | 140 +- SECURITY.md | 82 +- build.gradle.kts | 77 - config/dependency-check-suppressions.xml | 15 - docs/.well-known/security.txt | 6 - .../0001-selector-verifier-session-core.md | 29 - docs/authentication.md | 58 - docs/changelog.md | 1 - docs/configuration.md | 87 - docs/database.md | 56 - docs/development.md | 258 -- docs/future-roadmap.md | 425 -- docs/github-pages-setup.md | 295 -- docs/index.md | 114 - docs/interface-first-design.md | 393 -- docs/quickstart.md | 323 -- docs/react-integration.md | 539 --- docs/security.md | 300 -- docs/sub-tokens.md | 196 - docs/versioning-guide.md | 408 -- examples/spring-app/README.md | 15 + examples/spring-app/pom.xml | 15 + .../main/java/example/ogiri/Application.java | 128 + .../src/main/resources/application.properties | 8 + .../test/java/example/ogiri/ConsumerTest.java | 105 + gradle.properties | 23 - gradle/gradle-daemon-jvm.properties | 12 - gradle/version.gradle.kts | 18 - gradle/wrapper/gradle-wrapper.jar | Bin 48462 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 9 - gradlew | 248 -- gradlew.bat | 82 - jitpack.yml | 5 - lefthook.yml | 10 - mkdocs.yml | 65 - ogiri-bom/build.gradle.kts | 74 - ogiri-caffeine/build.gradle.kts | 146 - .../caffeine/CaffeineOgiriTokenLookupCache.kt | 62 - .../OgiriCaffeineAutoConfiguration.kt | 47 - ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../CaffeineOgiriTokenLookupCacheTest.kt | 122 - .../OgiriCaffeineAutoConfigurationTest.kt | 119 - ogiri-client/.oxfmtrc.json | 6 - ogiri-client/LICENSE | 201 - ogiri-client/README.md | 33 - ogiri-client/oxlintrc.json | 11 - ogiri-client/package.json | 54 - ogiri-client/pnpm-lock.yaml | 2348 ----------- ogiri-client/src/auth.ts | 111 - ogiri-client/src/errors.ts | 13 - ogiri-client/src/fetch-client.ts | 108 - ogiri-client/src/index.ts | 14 - ogiri-client/src/interceptors.ts | 163 - ogiri-client/src/token-storage.ts | 71 - ogiri-client/src/types.ts | 68 - ogiri-client/tests/auth.test.ts | 203 - ogiri-client/tests/fetch-client.test.ts | 152 - ogiri-client/tests/interceptors.test.ts | 151 - ogiri-client/tests/token-storage.test.ts | 81 - ogiri-client/tsconfig.json | 21 - ogiri-client/tsup.config.ts | 10 - ogiri-client/vitest.config.ts | 18 - ogiri-core/build.gradle.kts | 193 - .../config/OgiriConfigurationProperties.kt | 507 --- .../config/OgiriLookupTypeCondition.kt | 51 - .../config/OgiriMissingBeanFailureAnalyzer.kt | 137 - .../config/OgiriSecurityAutoConfiguration.kt | 384 -- .../ogiri/security/core/AuthHeader.kt | 275 -- .../ogiri/security/core/IdentifierPolicy.kt | 71 - .../ogiri/security/core/JsonCodec.kt | 26 - .../ogiri/security/core/OgiriService.kt | 27 - .../security/core/SecurityServiceException.kt | 18 - .../helpers/AuthenticationBypassDecider.kt | 44 - .../ogiri/security/helpers/SecurityHelpers.kt | 125 - .../ogiri/security/routes/OgiriRoute.kt | 132 - .../security/routes/OgiriRouteCatalog.kt | 51 - .../security/routes/OgiriRouteRegistry.kt | 24 - .../security/session/OgiriHttpConfigurer.kt | 162 - .../security/session/OgiriProblemHandler.kt | 80 - .../security/session/OgiriRateLimiter.kt | 38 - .../session/OgiriSessionAuthentication.kt | 157 - .../session/OgiriSessionAutoConfiguration.kt | 329 -- .../security/session/OgiriSessionCleanup.kt | 120 - .../security/session/OgiriSessionEndpoints.kt | 250 -- .../security/session/OgiriSessionMetrics.kt | 45 - ...riSessionObservabilityAutoConfiguration.kt | 40 - .../session/OgiriSessionProperties.kt | 153 - .../security/session/OgiriSessionResponses.kt | 127 - .../ogiri/security/spi/NoOpOgiriAuditHook.kt | 25 - .../security/spi/NoOpOgiriRateLimitHook.kt | 26 - .../ogiri/security/spi/OgiriAuditHook.kt | 74 - .../ogiri/security/spi/OgiriCacheKey.kt | 28 - .../ogiri/security/spi/OgiriRateLimitHook.kt | 61 - .../security/spi/OgiriSpringCacheAdapter.kt | 90 - .../security/spi/OgiriTokenLookupCache.kt | 78 - .../ogiri/security/spi/OgiriUser.kt | 24 - .../ogiri/security/spi/OgiriUserDirectory.kt | 53 - .../tokens/DefaultOgiriSubTokenRegistry.kt | 22 - .../DefaultOgiriTokenServiceResolver.kt | 64 - .../ogiri/security/tokens/OgiriBaseToken.kt | 121 - .../tokens/OgiriSubTokenRegistration.kt | 65 - .../security/tokens/OgiriSubTokenRegistry.kt | 22 - .../ogiri/security/tokens/OgiriToken.kt | 145 - .../security/tokens/OgiriTokenCleanupJob.kt | 43 - .../security/tokens/OgiriTokenExtensions.kt | 37 - .../security/tokens/OgiriTokenRepository.kt | 264 -- .../security/tokens/OgiriTokenService.kt | 1009 ----- .../tokens/OgiriTokenServiceResolver.kt | 23 - .../ogiri/security/tokens/OgiriTokenType.kt | 76 - .../ogiri/security/tokens/SubTokenService.kt | 286 -- .../web/OgiriAuthenticationEntryPoint.kt | 117 - .../web/OgiriTokenAuthenticationFilter.kt | 280 -- ...itional-spring-configuration-metadata.json | 131 - ...ot.autoconfigure.AutoConfiguration.imports | 2 - ...k.boot.diagnostics.FailureAnalyzer.imports | 1 - .../ogiri/db/ogiri-tokens-mongodb.js | 77 - .../ogiri/db/ogiri-user-tokens-h2.sql | 41 - .../ogiri/db/ogiri-user-tokens-mysql.sql | 34 - .../resources/ogiri/db/ogiri-user-tokens.sql | 44 - .../OgiriMissingBeanFailureAnalyzerTest.kt | 84 - ...iriPasswordEncoderAutoConfigurationTest.kt | 43 - .../OgiriSecurityAutoConfigurationCsrfTest.kt | 147 - ...giriSecurityAutoConfigurationWiringTest.kt | 272 -- .../security/core/AuthHeaderCookieTest.kt | 133 - .../ogiri/security/core/AuthHeaderTest.kt | 178 - .../AuthenticationBypassDeciderTest.kt | 99 - .../security/helpers/SecurityHelpersTest.kt | 171 - .../OgiriBearerAuthenticationConverterTest.kt | 76 - .../session/OgiriCookieSecurityTest.kt | 99 - .../session/OgiriEndpointTransactionTest.kt | 134 - .../session/OgiriProblemHandlerTest.kt | 45 - .../security/session/OgiriSecureChainTest.kt | 184 - .../session/OgiriSessionCleanupTest.kt | 167 - .../session/OgiriSessionPropertiesTest.kt | 37 - .../session/OgiriSessionResponseWriterTest.kt | 69 - .../testutil/InMemoryTokenRepository.kt | 176 - .../ogiri/security/testutil/TestFixtures.kt | 132 - .../ogiri/security/testutil/TestToken.kt | 100 - .../tokens/OgiriTokenLookupCacheTest.kt | 253 -- .../tokens/OgiriTokenServiceHookTest.kt | 530 --- .../OgiriTokenServiceOpenMethodsTest.kt | 54 - .../OgiriTokenServiceSetterInjectionTest.kt | 335 -- .../tokens/OgiriTokenServiceTimingTest.kt | 107 - .../security/tokens/OgiriTokenTypeTest.kt | 48 - .../ogiri/security/tokens/TokenCleanupTest.kt | 293 -- .../tokens/TokenServiceSubTokenTest.kt | 423 -- .../security/tokens/TryDecodeSubBearerTest.kt | 156 - .../web/OgiriAuthenticationEntryPointTest.kt | 144 - .../web/OgiriTokenAuthenticationFilterTest.kt | 224 - .../OgiriEndpointAutoConfigurationTest.kt | 103 - .../ogiri/security/OgiriStubToken.kt | 34 - ogiri-jdbc/build.gradle.kts | 148 - .../ogiri/jdbc/OgiriBaseTokenRow.kt | 47 - .../ogiri/jdbc/OgiriJdbcAutoConfiguration.kt | 40 - .../ogiri/jdbc/OgiriJdbcTokenRepository.kt | 231 -- ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../jdbc/OgiriJdbcTokenRepositoryTest.kt | 541 --- ogiri-jpa/build.gradle.kts | 151 - .../ogiri/jpa/OgiriBaseTokenEntity.kt | 127 - .../ogiri/jpa/OgiriJpaAutoConfiguration.kt | 48 - .../ogiri/jpa/OgiriJpaEntityScanRegistrar.kt | 35 - .../ogiri/jpa/OgiriJpaJobLease.kt | 94 - .../ogiri/jpa/OgiriJpaSessionStore.kt | 338 -- .../ogiri/jpa/OgiriSessionEntity.kt | 112 - ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../migration/V4__create_ogiri_sessions.sql | 73 - .../jpa/OgiriJpaStarterIntegrationTest.kt | 332 -- ogiri-redis/build.gradle.kts | 164 - .../redis/OgiriRedisAutoConfiguration.kt | 71 - .../security/redis/OgiriRedisRateLimiter.kt | 74 - .../redis/RedisOgiriTokenLookupCache.kt | 104 - ...ot.autoconfigure.AutoConfiguration.imports | 2 - .../redis/OgiriRedisAutoConfigurationTest.kt | 127 - .../redis/OgiriRedisRateLimiterTest.kt | 91 - .../redis/RedisOgiriTokenLookupCacheTest.kt | 115 - ogiri-session-core/build.gradle.kts | 93 - .../ogiri/session/IdentifierGenerator.kt | 29 - .../ogiri/session/OgiriSessions.kt | 66 - .../ogiri/session/SessionManager.kt | 363 -- .../ogiri/session/SessionModel.kt | 239 -- .../ogiri/session/SessionStore.kt | 116 - .../quantipixels/ogiri/session/TokenCodec.kt | 98 - .../quantipixels/ogiri/session/TokenHasher.kt | 77 - .../ogiri/session/JavaConsumerTest.java | 60 - .../ogiri/session/SessionManagerTest.kt | 327 -- .../ogiri/session/SessionModelTest.kt | 61 - ogiri-spring-security/pom.xml | 12 + .../spring/OgiriOpaqueTokenIntrospector.java | 59 + .../OgiriOpaqueTokenIntrospectorTest.java | 86 + ogiri-test/build.gradle.kts | 85 - .../ogiri/test/InMemorySessionStore.kt | 151 - .../ogiri/test/OgiriTestSupport.kt | 62 - ogiri/pom.xml | 10 + .../com/quantipixels/ogiri/IssuedSession.java | 19 + .../quantipixels/ogiri/PostgresSessions.java | 189 + .../java/com/quantipixels/ogiri/Session.java | 9 + .../ogiri/SessionLimitException.java | 8 + .../com/quantipixels/ogiri/SessionPolicy.java | 19 + .../ogiri/SessionStoreException.java | 8 + .../java/com/quantipixels/ogiri/Subject.java | 20 + .../java/com/quantipixels/ogiri/Tokens.java | 47 + .../META-INF/ogiri/schema-postgresql.sql | 14 + .../ogiri/PostgresSessionsTest.java | 217 + plans/client-distroless-redesign.md | 501 --- pnpm-lock.yaml | 3626 ----------------- pnpm-workspace.yaml | 2 - pom.xml | 34 + pyproject.toml | 22 - release.gradle.kts | 123 - sample/README.md | 185 - sample/sample-java/README.md | 353 -- sample/sample-java/build.gradle.kts | 48 - .../ogiri/samples/java/Application.java | 34 - .../java/controller/AuthController.java | 144 - .../java/controller/DemoController.java | 113 - .../java/controller/HealthController.java | 40 - .../java/controller/TestController.java | 85 - .../samples/java/entity/SampleToken.java | 39 - .../samples/java/jdbc/JdbcSampleToken.java | 29 - .../java/jdbc/JdbcSampleTokenRepository.java | 68 - .../java/jdbc/JdbcSampleTokenService.java | 78 - .../repository/SampleTokenRepository.java | 88 - .../security/SampleOgiriUserDirectory.java | 134 - .../java/security/SampleRouteRegistry.java | 36 - .../java/service/SampleTokenService.java | 76 - .../samples/java/util/SampleAuthUtils.java | 57 - .../ogiri/samples/java/FullCycleHttpTest.java | 199 - .../java/TokenServiceIntegrationTest.java | 143 - .../ogiri/samples/java/V4QuickstartTest.java | 46 - .../java/controller/HealthControllerTest.java | 50 - .../jdbc/JdbcSampleTokenRepositoryTest.java | 128 - .../repository/SampleTokenRepositoryTest.java | 140 - .../java/security/SecurityConfigTest.java | 51 - .../java/security/UserDirectoryTest.java | 76 - sample/sample-kotlin/README.md | 386 -- sample/sample-kotlin/build.gradle.kts | 58 - .../ogiri/samples/kotlin/Application.kt | 39 - .../kotlin/controller/AuthController.kt | 166 - .../kotlin/controller/DemoController.kt | 109 - .../kotlin/controller/HealthController.kt | 38 - .../kotlin/controller/TestController.kt | 78 - .../samples/kotlin/entity/DirectToken.kt | 77 - .../samples/kotlin/entity/SampleToken.kt | 43 - .../samples/kotlin/jdbc/JdbcSampleToken.kt | 26 - .../kotlin/jdbc/JdbcSampleTokenRepository.kt | 54 - .../kotlin/jdbc/JdbcSampleTokenService.kt | 77 - .../repository/SampleTokenRepository.kt | 70 - .../security/SampleOgiriUserDirectory.kt | 80 - .../kotlin/security/SampleRouteRegistry.kt | 33 - .../kotlin/service/SampleTokenService.kt | 72 - .../samples/kotlin/util/SampleAuthUtils.kt | 36 - .../ogiri/samples/kotlin/FullCycleHttpTest.kt | 206 - .../kotlin/TokenServiceIntegrationTest.kt | 146 - .../ogiri/samples/kotlin/V4QuickstartTest.kt | 47 - .../kotlin/controller/HealthControllerTest.kt | 52 - .../jdbc/JdbcSampleTokenRepositoryTest.kt | 132 - .../repository/SampleTokenRepositoryTest.kt | 142 - .../kotlin/security/SecurityConfigTest.kt | 47 - .../kotlin/security/UserDirectoryTest.kt | 79 - sample/sample-react/README.md | 69 - sample/sample-react/index.html | 12 - sample/sample-react/oxlintrc.json | 11 - sample/sample-react/package.json | 40 - .../sample-react/public/mockServiceWorker.js | 349 -- sample/sample-react/run-live.sh | 83 - sample/sample-react/src/App.tsx | 23 - sample/sample-react/src/api/client.ts | 14 - sample/sample-react/src/api/queries.ts | 73 - sample/sample-react/src/auth/AuthProvider.tsx | 28 - .../sample-react/src/auth/ProtectedRoute.tsx | 9 - sample/sample-react/src/auth/useAuth.ts | 8 - .../src/components/BackendBadge.tsx | 30 - .../src/components/TokenDisplay.tsx | 40 - .../src/lib/auth.integration.test.ts | 69 - sample/sample-react/src/lib/auth.ts | 298 -- sample/sample-react/src/lib/axios-ogiri.ts | 63 - sample/sample-react/src/main.tsx | 35 - sample/sample-react/src/mocks/browser.ts | 4 - sample/sample-react/src/mocks/db.test.ts | 151 - sample/sample-react/src/mocks/db.ts | 124 - sample/sample-react/src/mocks/handlers.ts | 103 - .../sample-react/src/pages/DashboardPage.tsx | 68 - sample/sample-react/src/pages/LoginPage.tsx | 64 - sample/sample-react/src/styles/app.css | 164 - sample/sample-react/tsconfig.json | 17 - sample/sample-react/tsconfig.tsbuildinfo | 1 - sample/sample-react/vite.config.ts | 14 - scripts/publish-docs.sh | 35 - scripts/release.sh | 136 - settings.gradle.kts | 73 - spotless.license.kt | 12 - test-samples.sh | 175 - 307 files changed, 1127 insertions(+), 37695 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 .coderabbit.yaml delete mode 100644 .gitattributes delete mode 100644 .github/dependabot.yml delete mode 100644 .java-version delete mode 100644 .jitpack.yml delete mode 100644 .ogiri-version delete mode 100644 .sdkmanrc delete mode 100644 CHANGELOG.md delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTEXT.md delete mode 100644 PUBLISHING.md delete mode 100644 build.gradle.kts delete mode 100644 config/dependency-check-suppressions.xml delete mode 100644 docs/.well-known/security.txt delete mode 100644 docs/adr/0001-selector-verifier-session-core.md delete mode 100644 docs/authentication.md delete mode 120000 docs/changelog.md delete mode 100644 docs/configuration.md delete mode 100644 docs/database.md delete mode 100644 docs/development.md delete mode 100644 docs/future-roadmap.md delete mode 100644 docs/github-pages-setup.md delete mode 100644 docs/index.md delete mode 100644 docs/interface-first-design.md delete mode 100644 docs/quickstart.md delete mode 100644 docs/react-integration.md delete mode 100644 docs/security.md delete mode 100644 docs/sub-tokens.md delete mode 100644 docs/versioning-guide.md create mode 100644 examples/spring-app/README.md create mode 100644 examples/spring-app/pom.xml create mode 100644 examples/spring-app/src/main/java/example/ogiri/Application.java create mode 100644 examples/spring-app/src/main/resources/application.properties create mode 100644 examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java delete mode 100644 gradle.properties delete mode 100644 gradle/gradle-daemon-jvm.properties delete mode 100644 gradle/version.gradle.kts delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100755 gradlew delete mode 100644 gradlew.bat delete mode 100644 jitpack.yml delete mode 100644 lefthook.yml delete mode 100644 mkdocs.yml delete mode 100644 ogiri-bom/build.gradle.kts delete mode 100644 ogiri-caffeine/build.gradle.kts delete mode 100644 ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCache.kt delete mode 100644 ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfiguration.kt delete mode 100644 ogiri-caffeine/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCacheTest.kt delete mode 100644 ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfigurationTest.kt delete mode 100644 ogiri-client/.oxfmtrc.json delete mode 100644 ogiri-client/LICENSE delete mode 100644 ogiri-client/README.md delete mode 100644 ogiri-client/oxlintrc.json delete mode 100644 ogiri-client/package.json delete mode 100644 ogiri-client/pnpm-lock.yaml delete mode 100644 ogiri-client/src/auth.ts delete mode 100644 ogiri-client/src/errors.ts delete mode 100644 ogiri-client/src/fetch-client.ts delete mode 100644 ogiri-client/src/index.ts delete mode 100644 ogiri-client/src/interceptors.ts delete mode 100644 ogiri-client/src/token-storage.ts delete mode 100644 ogiri-client/src/types.ts delete mode 100644 ogiri-client/tests/auth.test.ts delete mode 100644 ogiri-client/tests/fetch-client.test.ts delete mode 100644 ogiri-client/tests/interceptors.test.ts delete mode 100644 ogiri-client/tests/token-storage.test.ts delete mode 100644 ogiri-client/tsconfig.json delete mode 100644 ogiri-client/tsup.config.ts delete mode 100644 ogiri-client/vitest.config.ts delete mode 100644 ogiri-core/build.gradle.kts delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriConfigurationProperties.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriLookupTypeCondition.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzer.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfiguration.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/AuthHeader.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/IdentifierPolicy.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/JsonCodec.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/OgiriService.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/SecurityServiceException.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDecider.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpers.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRoute.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteCatalog.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteRegistry.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriHttpConfigurer.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandler.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriRateLimiter.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAuthentication.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAutoConfiguration.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanup.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionEndpoints.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionMetrics.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionObservabilityAutoConfiguration.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionProperties.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponses.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriAuditHook.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriRateLimitHook.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriAuditHook.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriCacheKey.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriRateLimitHook.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriSpringCacheAdapter.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriTokenLookupCache.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUser.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUserDirectory.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriSubTokenRegistry.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriTokenServiceResolver.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriBaseToken.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistration.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistry.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriToken.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenCleanupJob.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenExtensions.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenRepository.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceResolver.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenType.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/SubTokenService.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPoint.kt delete mode 100644 ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilter.kt delete mode 100644 ogiri-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json delete mode 100644 ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.diagnostics.FailureAnalyzer.imports delete mode 100644 ogiri-core/src/main/resources/ogiri/db/ogiri-tokens-mongodb.js delete mode 100644 ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-h2.sql delete mode 100644 ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-mysql.sql delete mode 100644 ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens.sql delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzerTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriPasswordEncoderAutoConfigurationTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationCsrfTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationWiringTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderCookieTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDeciderTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpersTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriBearerAuthenticationConverterTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriCookieSecurityTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriEndpointTransactionTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandlerTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSecureChainTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanupTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionPropertiesTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponseWriterTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/InMemoryTokenRepository.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestFixtures.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestToken.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenLookupCacheTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceHookTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceOpenMethodsTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceSetterInjectionTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceTimingTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenTypeTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenCleanupTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenServiceSubTokenTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TryDecodeSubBearerTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPointTest.kt delete mode 100644 ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilterTest.kt delete mode 100644 ogiri-core/src/test/kotlin/example/consumer/OgiriEndpointAutoConfigurationTest.kt delete mode 100644 ogiri-core/src/testFixtures/kotlin/com/quantipixels/ogiri/security/OgiriStubToken.kt delete mode 100644 ogiri-jdbc/build.gradle.kts delete mode 100644 ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriBaseTokenRow.kt delete mode 100644 ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcAutoConfiguration.kt delete mode 100644 ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepository.kt delete mode 100644 ogiri-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 ogiri-jdbc/src/test/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepositoryTest.kt delete mode 100644 ogiri-jpa/build.gradle.kts delete mode 100644 ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriBaseTokenEntity.kt delete mode 100644 ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaAutoConfiguration.kt delete mode 100644 ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaEntityScanRegistrar.kt delete mode 100644 ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaJobLease.kt delete mode 100644 ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaSessionStore.kt delete mode 100644 ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriSessionEntity.kt delete mode 100644 ogiri-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 ogiri-jpa/src/main/resources/db/migration/V4__create_ogiri_sessions.sql delete mode 100644 ogiri-jpa/src/test/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaStarterIntegrationTest.kt delete mode 100644 ogiri-redis/build.gradle.kts delete mode 100644 ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfiguration.kt delete mode 100644 ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiter.kt delete mode 100644 ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCache.kt delete mode 100644 ogiri-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfigurationTest.kt delete mode 100644 ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiterTest.kt delete mode 100644 ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCacheTest.kt delete mode 100644 ogiri-session-core/build.gradle.kts delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/IdentifierGenerator.kt delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/OgiriSessions.kt delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionManager.kt delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionModel.kt delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionStore.kt delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenCodec.kt delete mode 100644 ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenHasher.kt delete mode 100644 ogiri-session-core/src/test/java/com/quantipixels/ogiri/session/JavaConsumerTest.java delete mode 100644 ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionManagerTest.kt delete mode 100644 ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionModelTest.kt create mode 100644 ogiri-spring-security/pom.xml create mode 100644 ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java create mode 100644 ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java delete mode 100644 ogiri-test/build.gradle.kts delete mode 100644 ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/InMemorySessionStore.kt delete mode 100644 ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/OgiriTestSupport.kt create mode 100644 ogiri/pom.xml create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/IssuedSession.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/Session.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/SessionLimitException.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/SessionPolicy.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/Subject.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java create mode 100644 ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql create mode 100644 ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java delete mode 100644 plans/client-distroless-redesign.md delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml create mode 100644 pom.xml delete mode 100644 pyproject.toml delete mode 100644 release.gradle.kts delete mode 100644 sample/README.md delete mode 100644 sample/sample-java/README.md delete mode 100644 sample/sample-java/build.gradle.kts delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/Application.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/AuthController.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/DemoController.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/HealthController.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/TestController.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/entity/SampleToken.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleToken.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepository.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenService.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepository.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleOgiriUserDirectory.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleRouteRegistry.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/service/SampleTokenService.java delete mode 100644 sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/util/SampleAuthUtils.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/FullCycleHttpTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/TokenServiceIntegrationTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/V4QuickstartTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/controller/HealthControllerTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepositoryTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepositoryTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/SecurityConfigTest.java delete mode 100644 sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/UserDirectoryTest.java delete mode 100644 sample/sample-kotlin/README.md delete mode 100644 sample/sample-kotlin/build.gradle.kts delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/Application.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/AuthController.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/DemoController.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthController.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/TestController.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/DirectToken.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/SampleToken.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleToken.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepository.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenService.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepository.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleOgiriUserDirectory.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleRouteRegistry.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/service/SampleTokenService.kt delete mode 100644 sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/util/SampleAuthUtils.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/FullCycleHttpTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/TokenServiceIntegrationTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/V4QuickstartTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthControllerTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepositoryTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepositoryTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SecurityConfigTest.kt delete mode 100644 sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/UserDirectoryTest.kt delete mode 100644 sample/sample-react/README.md delete mode 100644 sample/sample-react/index.html delete mode 100644 sample/sample-react/oxlintrc.json delete mode 100644 sample/sample-react/package.json delete mode 100644 sample/sample-react/public/mockServiceWorker.js delete mode 100755 sample/sample-react/run-live.sh delete mode 100644 sample/sample-react/src/App.tsx delete mode 100644 sample/sample-react/src/api/client.ts delete mode 100644 sample/sample-react/src/api/queries.ts delete mode 100644 sample/sample-react/src/auth/AuthProvider.tsx delete mode 100644 sample/sample-react/src/auth/ProtectedRoute.tsx delete mode 100644 sample/sample-react/src/auth/useAuth.ts delete mode 100644 sample/sample-react/src/components/BackendBadge.tsx delete mode 100644 sample/sample-react/src/components/TokenDisplay.tsx delete mode 100644 sample/sample-react/src/lib/auth.integration.test.ts delete mode 100644 sample/sample-react/src/lib/auth.ts delete mode 100644 sample/sample-react/src/lib/axios-ogiri.ts delete mode 100644 sample/sample-react/src/main.tsx delete mode 100644 sample/sample-react/src/mocks/browser.ts delete mode 100644 sample/sample-react/src/mocks/db.test.ts delete mode 100644 sample/sample-react/src/mocks/db.ts delete mode 100644 sample/sample-react/src/mocks/handlers.ts delete mode 100644 sample/sample-react/src/pages/DashboardPage.tsx delete mode 100644 sample/sample-react/src/pages/LoginPage.tsx delete mode 100644 sample/sample-react/src/styles/app.css delete mode 100644 sample/sample-react/tsconfig.json delete mode 100644 sample/sample-react/tsconfig.tsbuildinfo delete mode 100644 sample/sample-react/vite.config.ts delete mode 100755 scripts/publish-docs.sh delete mode 100755 scripts/release.sh delete mode 100644 settings.gradle.kts delete mode 100644 spotless.license.kt delete mode 100755 test-samples.sh diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 169bb50..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "project": { - "name": "ogiri-security", - "type": "kotlin-spring-security-library", - "language": "kotlin", - "framework": "spring-boot", - "javaVersion": "17" - }, - "build": { - "tool": "gradle", - "wrapper": true, - "commands": { - "build": "./gradlew build", - "test": "./gradlew test", - "format": "./gradlew spotlessApply", - "formatCheck": "./gradlew spotlessCheck", - "coverage": "./gradlew test jacocoTestReport", - "securityCheck": "./gradlew dependencyCheckAnalyze" - } - }, - "conventions": { - "formatting": { - "kotlin": "ktfmt", - "java": "google-java-format" - }, - "licenseHeader": "spotless.license.kt", - "testCoverage": { - "minimum": 50, - "tool": "jacoco" - } - }, - "paths": { - "source": "ogiri-core/src/main/kotlin", - "test": "ogiri-core/src/test/kotlin", - "samples": ["sample/sample-java", "sample/sample-kotlin"], - "docs": "docs" - } -} diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index ed83727..0000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json -language: "en-US" -early_access: false -reviews: - profile: "chill" - request_changes_workflow: false - high_level_summary: true - poem: true - review_status: true - collapse_walkthrough: false - auto_review: - enabled: true - drafts: false -chat: - auto_reply: true diff --git a/.editorconfig b/.editorconfig index 106c6fd..05008f4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,15 +1,7 @@ root = true - [*] charset = utf-8 end_of_line = lf -indent_size = 4 -indent_style = space insert_final_newline = true -max_line_length = 120 -tab_width = 4 -trim_trailing_whitespace = true -ij_wrap_on_typing = false - -[*.{kt,kts,yml,yaml,md}] -indent_size = 2 +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 51e04fa..0000000 --- a/.gitattributes +++ /dev/null @@ -1,21 +0,0 @@ -# Git attributes for Ogiri project - -# Exclude AI assistant documentation from release archives -sample/**/CLAUDE.md export-ignore -CLAUDE.md export-ignore -AGENTS.md export-ignore - -# Exclude development files from release archives -.claude/ export-ignore -.coderabbit.yaml export-ignore -test-samples.sh export-ignore - -# Ensure consistent line endings -* text=auto -*.kt text eol=lf -*.java text eol=lf -*.md text eol=lf -*.yml text eol=lf -*.yaml text eol=lf -*.gradle.kts text eol=lf -*.sh text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c274ec8..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,35 +0,0 @@ -version: 2 -updates: - # Gradle dependencies and plugins - - package-ecosystem: "gradle" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "03:00" - open-pull-requests-limit: 5 - reviewers: - - "quantipixels" - labels: - - "dependencies" - - "gradle" - commit-message: - prefix: "chore(deps):" - include: "scope" - - # GitHub Actions workflows - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "03:30" - open-pull-requests-limit: 5 - reviewers: - - "quantipixels" - labels: - - "dependencies" - - "ci/cd" - commit-message: - prefix: "ci:" - include: "scope" diff --git a/.gitignore b/.gitignore index 23f7fe9..ff56490 100644 --- a/.gitignore +++ b/.gitignore @@ -1,65 +1,6 @@ -HELP.md -.gradle -.gradle-home/ -.gradle-local/ -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ -application*.yml -application*.properties -!application.properties - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### +**/target/ +.idea/ .vscode/ - -### Kotlin ### -.kotlin - -### AI ### -.claude/ -.tldr/ -.tldrignore -.ai/ -.archive/ -.trae - -lefthook-local.yml -logs/ +*.iml +.env .DS_Store -site/ -.claude/settings.local.json -.analysis/ -node_modules/ -dist/ -**/coverage/ -reports/ -plans/ diff --git a/.java-version b/.java-version deleted file mode 100644 index 98d9bcb..0000000 --- a/.java-version +++ /dev/null @@ -1 +0,0 @@ -17 diff --git a/.jitpack.yml b/.jitpack.yml deleted file mode 100644 index 121264d..0000000 --- a/.jitpack.yml +++ /dev/null @@ -1,20 +0,0 @@ -# JitPack build configuration for Ogiri Security -# Builds both ogiri-core and ogiri-jpa modules -# -# Usage: -# repositories { -# maven { url = uri("https://jitpack.io") } -# } -# dependencies { -# implementation("com.github.quantipixels.ogiri:ogiri-core:TAG") -# implementation("com.github.quantipixels.ogiri:ogiri-jpa:TAG") -# } - -jdk: - - openjdk17 - -before_install: - - chmod +x gradlew - -install: - - ./gradlew clean publishToMavenLocal -x test -x spotlessCheck diff --git a/.ogiri-version b/.ogiri-version deleted file mode 100644 index fcdb2e1..0000000 --- a/.ogiri-version +++ /dev/null @@ -1 +0,0 @@ -4.0.0 diff --git a/.sdkmanrc b/.sdkmanrc deleted file mode 100644 index 13e1bc0..0000000 --- a/.sdkmanrc +++ /dev/null @@ -1,3 +0,0 @@ -# SDKMAN! auto-env configuration -# Auto-switches to Java 17 when entering this directory -java=17.0.14-jbr diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 1a88d26..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,175 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [3.1.0] - 2026-03-28 - -### Security - -- Replace truncated `DUMMY_HASH` constant with a runtime-generated BCrypt hash to close timing oracle on unknown-user login path - -### Added - -- JaCoCo coverage gates for `ogiri-jdbc` (85% baseline) and `ogiri-jpa` (0% baseline, no tests yet) -- Three new Redis integration tests covering cache miss, targeted eviction, and empty-keyspace `evictAll` -- Route authorization gap documented in `OgiriSecurityAutoConfiguration` KDoc and `docs/quickstart.md` - -### Changed - -- Sub-bearer logic extracted from `OgiriTokenService` into `SubTokenService` (delegation, no API change) -- `OgiriBaseTokenEntity.expiryAt` is now a required constructor parameter (no silent `Instant.now()` default) -- Testcontainers pinned to 1.20.4 in `ogiri-redis` to restore Docker Desktop 29.x compatibility - ---- - -## [3.0.1] - 2026-03-01 - -### Fixed - -- Redis deserialization: `JavaTimeModule` now registered via `configure {}` without replacing the ObjectMapper's polymorphic type resolver - -### Removed - -- `ogiri-client` TypeScript package (already retired; auth primitives are inlined in the React sample) - ---- - -## [3.0.0] - 2026-02-27 - -### Breaking - -- `OgiriTokenService` optional collaborators (`auditHook`, `rateLimitHook`, `lookupCache`) moved from constructor params to setter injection (`setAuditHook()`, `setRateLimitHook()`, `setLookupCache()`). The constructor now requires only the six mandatory collaborators. - -### Added - -- `NoOpOgiriAuditHook` and `NoOpOgiriRateLimitHook` — public singleton no-ops for use in tests and explicit resets - ---- - -## [2.1.0] - 2026-02-24 - -### Breaking - -- `OgiriTokenService` constructor `ObjectProvider` params replaced with nullable direct references (`auditHook: OgiriAuditHook? = null`, etc.) - -### Added - -- `ogiri-jdbc` — Spring `JdbcClient`-based token repository adapter -- `ogiri-caffeine` — optional in-process Caffeine lookup cache -- `ogiri-redis` — optional distributed Redis lookup cache -- `OgiriTokenLookupCache` SPI wired into `OgiriTokenService` - ---- - -## [2.0.0] - 2026-02-10 (ogiri-security-client) - -### Breaking - -- `OgiriClient` removed; replaced by `OgiriAuth` (auth primitives) and `OgiriFetchClient` (optional fetch wrapper) - -### Added - -- `ogiri-security-client/axios` sub-entrypoint with `createAxiosInterceptors(auth)` -- `OgiriAuth.subscribe()` for auth state listeners -- `OgiriAuth.headerInjector()` for BYO HTTP clients - ---- - -## [1.4.1] - 2026-01-14 - -### Fixed - -- Auth cookies cleared on 401 responses when `ogiri.cookies.enabled=true`, preventing stale-cookie 401 loops - ---- - -## [1.4.0] - 2026-01-08 - -### Breaking - -- Default token length increased from 16 to 32 characters -- Default `rotateStaleSeconds` changed from 0 to 3600 (secure by default) -- `deleteExpiredBatch` removed from `OgiriTokenRepository` (now internal to the service) - -### Security - -- Cache keys now use SHA-256 hashes instead of plaintext tokens -- Constant-time dummy password check added to `verifyUser` to prevent user-enumeration via timing - -### Changed - -- Batch token fetching via `findByUserIdAndClientIn` eliminates N+1 queries in `buildAuthHeader` - ---- - -## [1.3.1] - 2026-01-08 - -### Breaking - -- `OgiriTokenRepository` method names aligned to Spring Data conventions (e.g. `findAllByUserId` → `findByUserIdOrderByUpdatedAtDesc`); `findByUserIdAndClient` now returns `Optional` - -### Deprecated - -- `AbstractJpaTokenRepositoryAdapter` — use direct interface extension instead - ---- - -## [1.3.0] - 2025-01-08 - -### Added - -- `ogiri-jpa` module: `OgiriBaseTokenEntity` (`@MappedSuperclass`), `AbstractJpaTokenRepositoryAdapter`, and `OgiriJpaAutoConfiguration` -- `BCryptPasswordEncoder` auto-configuration when no `PasswordEncoder` bean is present -- `OgiriMissingBeanFailureAnalyzer` for actionable startup errors - ---- - -## [1.2.1] - 2025-01-07 - -### Added - -- Secure cookie support and batched token cleanup - -### Fixed - -- Bearer token auth returns 401 instead of 500 when authentication fails - ---- - -## [1.2.0] - 2025-12-12 - -### Breaking - -- Core classes renamed: `BaseToken` → `OgiriBaseToken`, `TokenRepository` → `OgiriTokenRepository`, `TokenService` → `OgiriTokenService`, `GeneratedTokens` → `OgiriGeneratedTokens` - ---- - -## [1.1.1] - 2025-12-11 - -### Added - -- GitHub Pages deployment with `mike` versioning - ---- - -## [1.1.0] - 2025-12-09 - -### Added - -- `OgiriUser.getOgiriUserId()` for conflict-free Java interop -- `OgiriSubTokenRegistration.validate()` and sub-token helpers (`getSubToken`, `revokeSubToken`, `renewSubTokenAndGetHeaders`) - ---- - -## [1.0.0] - 2025-12-05 - -### Added - -- Initial release: token authentication, sub-token support, Spring Boot auto-configuration, JPA/JDBC/NoSQL repository interface, configurable token rotation - -## License - -Apache License 2.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index a55ee69..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,98 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our community include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact:** Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. - -**Consequence:** A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact:** A violation through a single incident or series of actions. - -**Consequence:** A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. - -### 3. Temporary Ban - -**Community Impact:** A serious violation of community standards, including sustained inappropriate behavior. - -**Consequence:** A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact:** Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. - -**Consequence:** A permanent ban from any sort of public interaction within the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). - -Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). - ---- - -## Reporting Issues - -If you have experienced or witnessed a violation of this Code of Conduct, please report it to the project maintainers. All reports will be kept confidential and will be reviewed and investigated promptly. - -When reporting, please include: - -- **Your contact information** (so we can follow up with you) -- **Names (real, nicknames, or pseudonyms) of any individuals involved** -- **Your account/username** (if applicable) -- **Description of the incident** -- **Where the incident occurred** (GitHub issue, pull request, discussion, Discord, etc.) -- **Additional context** (logs, screenshots, links) - -## Acknowledgments - -Thank you for helping us create a welcoming and inclusive community. We are committed to making participation in our project a positive experience for everyone. diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index ab743e4..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,49 +0,0 @@ -# Ogiri Domain Language - -## Subject - -A stable identity that may own sessions. A Subject is identified by Realm, optional Tenant, and opaque Subject ID. Mutable login identifiers such as email addresses are not Subject IDs. - -## Realm - -A named authentication population with its own authority and identity rules. Equal Subject IDs in different Realms identify different Subjects. - -## Tenant - -An optional namespace within a Realm. Equal Subject IDs in different Tenants identify different Subjects. - -## Session - -A revocable relationship between one Subject and one Client. A Session has a stable Session ID, one credential family, lifecycle timestamps, and a monotonically increasing Version. - -## Client - -A caller installation or device label that owns one Session independently of a Subject's other Clients. A Client label is metadata, not proof of possession. - -## Session ID - -A stable, non-secret identity used to list, revoke, and audit a Session. It does not authenticate a caller. - -## Selector - -A random, non-secret credential prefix used to locate one Session without first identifying the Subject. - -## Verifier - -The high-entropy secret portion of a session credential. Possession proves authority to use the Session. Only a digest crosses the storage boundary. - -## Credential version - -The current or immediately previous Verifier state for a Session. A previous version always has one fixed validity deadline. - -## Credential family - -The lineage of credential versions belonging to one Session. Reuse detection revokes the family rather than accepting an older lineage member. - -## Revocation - -An authoritative state transition that makes a Session unusable. Revocation may target one Session or every Session owned by a Subject. - -## Issued session - -The one-time result that pairs committed Session metadata with a plaintext credential for transport. It is distinct from a stored Session. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b72ca56..a9029e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,121 +1,24 @@ -# Contributing to Ògiri +# Contributing -Thank you for your interest in contributing! +Use Java 17 or newer, Maven 3.9+, and a disposable PostgreSQL database. Tests deliberately fail rather than skip when the database is missing. They drop and recreate `ogiri_sessions`; **never point them at a development database containing valuable data or at production**. -## Quick Start - -```bash -# Clone and build -git clone https://github.com/quantipixels/ogiri.git -cd ogiri -./gradlew build - -# Run tests -./gradlew test - -# Format code -./gradlew spotlessApply -``` - -## Ways to Contribute - -### Reporting Bugs - -Before reporting, check [existing issues](https://github.com/quantipixels/ogiri/issues). - -Include: - -- Ògiri version -- Java/Spring Boot version -- Steps to reproduce -- Expected vs actual behavior - -### Proposing Features - -Open an issue describing: - -- Problem being solved -- Proposed solution -- Alternative approaches considered - -### Code Contributions - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/your-feature` -3. Make changes with tests -4. Format code: `./gradlew spotlessApply` -5. Run tests: `./gradlew test` -6. Push and create PR - -## Code Guidelines - -- Follow existing code style -- Format with `./gradlew spotlessApply` -- Write tests for new features -- Use descriptive test names: `` `should rotate token outside batch window` `` -- Keep lines under 120 characters - -## Commit Messages - -Use [Conventional Commits](https://www.conventionalcommits.org/): - -```text -feat: add chat sub-token renewal -fix: prevent expired token renewal -docs: add multi-tenant setup guide -test: add edge case for concurrent token creation -refactor: extract common validation logic +```sh +docker run --rm --name ogiri-test -e POSTGRES_USER=ogiri -e POSTGRES_PASSWORD=ogiri -e POSTGRES_DB=ogiri_test -p 127.0.0.1:5432:5432 -d postgres:16 +export OGIRI_TEST_JDBC_URL=jdbc:postgresql://localhost:5432/ogiri_test +export OGIRI_TEST_JDBC_USER=ogiri +export OGIRI_TEST_JDBC_PASSWORD=ogiri +mvn --batch-mode --no-transfer-progress clean install +mvn --batch-mode --no-transfer-progress -f examples/spring-app/pom.xml verify ``` -## Pull Request Template - -```markdown -## Description +The root build tests and installs the two code artifacts and their parent POM locally. The separate consumer verifies the installed dependency graph and real HTTP behaviour. CI also extracts and applies the exact SQL template from the built JAR before the consumer test. No Maven Central deployment is part of `install` or CI. -Brief description of changes +## Test admission -## Motivation +Every retained test must protect a material public contract, use an independent outcome oracle and name a plausible wrong implementation it would reject. Prefer the real PostgreSQL behaviour for transaction, lock, expiry and SQL claims. Test doubles are appropriate for an external fault that cannot be reliably induced otherwise, such as a controlled pre-commit failure or account-directory outage; they are not replacement stores. -Fixes #123 / Related to #456 +Do not add record-getter tests, mock call-order checks, assertions on private helpers, blanket coverage quotas, sleeps as clocks, or tests that restate implementation text. Reuse stronger existing coverage. Remove construction-history tests once a stronger public scenario subsumes them. A green suite is not sufficient evidence: challenge security predicates and resource boundaries with a focused mutation or negative control when practical. ## Changes -- Change 1 -- Change 2 - -## Testing - -- [ ] Unit tests added -- [ ] Manual testing performed - -## Checklist - -- [ ] Tests pass (`./gradlew test`) -- [ ] Code formatted (`./gradlew spotlessApply`) -- [ ] Documentation updated -``` - -## Areas for Contribution - -**High Priority:** - -- R2DBC examples for reactive SQL -- Spring Data JDBC integration guide -- GraphQL authentication example -- Performance benchmarking - -**Medium Priority:** - -- Additional NoSQL examples (Firestore, DynamoDB) -- OAuth2 integration examples -- Rate limiting examples - -## Getting Help - -- Questions: Open a GitHub Discussion -- Security issues: See [security.md](security.md) -- Development setup: See [development.md](development.md) - -## License - -By contributing, you agree that your contributions will be licensed under the Apache License 2.0. +Keep account policy in the application, session invariants in the core and framework transport in Spring Security. A new module, configuration switch, dependency, provider interface or persistent state field needs a concrete current consumer. Update the owning README/Javadoc/security section rather than adding an audit-report archive. Make coherent logical commits and non-force pushes; do not edit version tags or applied application migrations. diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index 197d0bb..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,183 +0,0 @@ -# Publishing Guide - -This document explains how to build and use the Ogiri Security library. - -## Modules - -- **ogiri-core**: Core security library (database-agnostic) -- **ogiri-jpa**: JPA adapter module (optional, for Spring Data JPA integration) - -## Publishing Methods - -### 1. Local Maven (Development) - -Install both modules to your local Maven repository: - -```bash -./gradlew publishToMavenLocal -``` - -Use in your project: - -```kotlin -repositories { - mavenLocal() - mavenCentral() -} - -dependencies { - implementation("com.quantipixels.ogiri:ogiri-core:1.3.0") - implementation("com.quantipixels.ogiri:ogiri-jpa:1.3.0") // Optional -} -``` - -### 2. JitPack (Public Access) - -JitPack builds directly from GitHub tags/branches. - -#### Usage - -```kotlin -repositories { - maven { url = uri("https://jitpack.io") } -} - -dependencies { - implementation("com.github.quantipixels.ogiri:ogiri-core:TAG") - implementation("com.github.quantipixels.ogiri:ogiri-jpa:TAG") // Optional -} -``` - -Replace `TAG` with: - -- A release tag: `1.3.0` -- A commit hash: `abc123` -- A branch name: `main-SNAPSHOT` - -#### Triggering Builds - -Visit https://jitpack.io/#quantipixels/ogiri to trigger builds for specific tags. - -### 3. Maven Central (Production) - -Publishing to Maven Central requires: - -- OSSRH account credentials (`OSSRH_USERNAME`, `OSSRH_PASSWORD`) -- GPG signing keys (`GPG_PRIVATE_KEY`, `GPG_PASSPHRASE`) - -```bash -# Publish to OSSRH staging -./gradlew publish - -# Or publish locally first, then to remote -./gradlew publishToMavenLocal publish -``` - -Consumer usage: - -```kotlin -repositories { - mavenCentral() -} - -dependencies { - implementation("com.quantipixels.ogiri:ogiri-core:1.3.0") - implementation("com.quantipixels.ogiri:ogiri-jpa:1.3.0") // Optional -} -``` - -## Version Management - -The project version is defined in `.ogiri-version` file. To change the version: - -1. Edit `.ogiri-version` -2. Commit the change -3. Tag the commit: `git tag v1.3.0` -4. Push: `git push origin v1.3.0` - -### Version Override - -You can override the version at build time: - -```bash -# Using environment variable -RELEASE_VERSION=1.4.0 ./gradlew publishToMavenLocal - -# Using Gradle property -./gradlew -PRELEASE_VERSION=1.4.0 publishToMavenLocal -``` - -## Verification - -### Check Local Installation - -```bash -ls -la ~/.m2/repository/com/quantipixels/ogiri/ogiri-core/1.3.0/ -ls -la ~/.m2/repository/com/quantipixels/ogiri/ogiri-jpa/1.3.0/ -``` - -### Verify POM Content - -```bash -cat ~/.m2/repository/com/quantipixels/ogiri/ogiri-core/1.3.0/ogiri-core-1.3.0.pom -cat ~/.m2/repository/com/quantipixels/ogiri/ogiri-jpa/1.3.0/ogiri-jpa-1.3.0.pom -``` - -Expected artifacts: - -- `ogiri-core-1.3.0.jar` - Main library -- `ogiri-core-1.3.0-sources.jar` - Source code -- `ogiri-core-1.3.0-javadoc.jar` - Javadoc -- `ogiri-core-1.3.0.pom` - Maven POM -- `ogiri-core-1.3.0.module` - Gradle metadata - -## Troubleshooting - -### Gradle Version Compatibility - -This project requires Gradle 9.x. If you encounter dependency resolution issues: - -1. Check Gradle version: `./gradlew --version` -2. Update Gradle wrapper if needed -3. Clear Gradle cache: `rm -rf ~/.gradle/caches/` - -### JitPack Build Failures - -If JitPack builds fail: - -1. Check build log at https://jitpack.io/com/github/quantipixels/ogiri/TAG/build.log -2. Ensure the tag exists on GitHub -3. Verify `jitpack.yml` is in the repository root - -### POM Dependency Issues - -If Maven projects can't resolve dependencies: - -1. Ensure BOM versions are resolved: Check `versionMapping` in `build.gradle.kts` -2. Verify POM includes `dependencyManagement` section -3. Check that Spring Boot BOM version (3.5.7) is compatible with your project - -## Build Configuration Details - -### ogiri-core - -- Group: `com.quantipixels.ogiri` -- Artifact: `ogiri-core` -- Java: 17 -- Kotlin: 2.1.0 -- Spring Boot BOM: 3.5.7 - -### ogiri-jpa - -- Group: `com.quantipixels.ogiri` -- Artifact: `ogiri-jpa` -- Depends on: `ogiri-core` (same version) -- Java: 17 -- Kotlin: 2.1.0 -- Requires: Spring Data JPA - -## Related Documentation - -- [Main README](README.md) - Project overview and usage -- [SECURITY.md](SECURITY.md) - Security policy -- [CLAUDE.md](CLAUDE.md) - Development guide for Claude Code diff --git a/README.md b/README.md index 45bf1f4..e3ca6f6 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,98 @@ -# Ogiri +# Ogiri 0.1.0 -Ogiri is a Spring Boot library for secure, database-backed opaque sessions. It provides a persistence-neutral session core, Spring Security integration, JPA storage, optional HTTP endpoints, distributed sign-in throttling, and test fixtures. +Revocable opaque sessions for applications that already own their accounts and use PostgreSQL. -## Features +Ogiri issues credentials, verifies them, enforces a per-account session limit, lists devices, revokes sessions and removes expired rows. It does not own login, passwords, cookies, HTTP endpoints or your security chain. The core is plain Java 17 with no third-party runtime dependencies. The optional adapter implements Spring Security's `OpaqueTokenIntrospector`. -- Selector/verifier credentials with digest-only persistence -- Immediate revocation against an authoritative session store -- Atomic credential rotation with bounded previous-token grace -- Session limits, listing, sign-out, and per-session revocation -- Bearer, secure cookie, and devise-token-auth compatibility transports -- Consumer-owned Spring Security authorization -- Drop-in JPA persistence and clustered cleanup leases -- Optional Redis-backed distributed sign-in throttling -- Java-friendly APIs and reusable in-memory test support -- Spring Boot metrics, health indicators, and RFC 9457 errors +**This is an unpublished greenfield 0.1.0 API, not a compatible downgrade from the old v3/v4 design.** Old tags and branches remain history, not this library's release lineage. Do not mix old and new schemas or credentials. -Ogiri v4 supports Java 17, Spring Boot 3.5, and servlet applications. +## Is this the right library? -## Modules +Use Spring Security with [Spring Session JDBC](https://docs.spring.io/spring-session/reference/configuration/jdbc.html) for ordinary browser `HttpSession` applications. Use an identity provider and Spring Security's resource server for OAuth/OIDC federation. Ogiri is useful when you specifically need application-issued, independently revocable bearer sessions whose plaintext credentials are absent from PostgreSQL. -| Module | Purpose | -| -------------------- | ------------------------------------------------------------------- | -| `ogiri-session-core` | Spring-free session state machine and store contract | -| `ogiri-core` | Spring Security, transport, endpoint, and observability integration | -| `ogiri-jpa` | Default JPA session store and database lease implementation | -| `ogiri-redis` | Distributed sign-in rate limiter | -| `ogiri-test` | In-memory store, fake clock, and test helpers | -| `ogiri-bom` | Aligned dependency versions for all Ogiri modules | +There is no refresh-token protocol, automatic rotation, idle timeout, cookie transport, distributed rate limiter, account registration or password-recovery system. Those are deliberate scope decisions, not hidden unfinished adapters. See [security boundaries](SECURITY.md) before adoption. -## Install +## Install locally -Use the BOM and select the adapters your application needs: +Until a release is published, build the repository with a disposable PostgreSQL database as described in [CONTRIBUTING.md](CONTRIBUTING.md). `mvn clean install` installs the actual artifacts into your local Maven repository; it does not publish to Central. -```kotlin -dependencies { - implementation(platform("com.quantipixels.ogiri:ogiri-bom:VERSION")) - implementation("com.quantipixels.ogiri:ogiri-jpa") +```xml + + com.quantipixels.ogiri + ogiri + 0.1.0 + +``` - implementation("org.flywaydb:flyway-core") - runtimeOnly("org.flywaydb:flyway-database-postgresql") - runtimeOnly("org.postgresql:postgresql") +Use `ogiri-spring-security` instead to include the Spring adapter and core. There are two code artifacts and one parent POM, not a BOM or a family of speculative stores. Supply your own PostgreSQL JDBC driver and connection pool. Both code artifacts have sources and generated Javadoc. - testImplementation("com.quantipixels.ogiri:ogiri-test") -} +## Provision the schema + +Copy `META-INF/ogiri/schema-postgresql.sql` from the core JAR into an application-owned migration. The library never creates tables or registers a Flyway migration. Configure the pool's PostgreSQL `search_path` to the schema containing `ogiri_sessions`; do not let untrusted accounts create objects there. + +```sh +unzip -p ogiri/target/ogiri-0.1.0.jar META-INF/ogiri/schema-postgresql.sql > schema.sql +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql ``` -Add `com.quantipixels.ogiri:ogiri-redis` when distributed rate limiting is required. +The schema has one table and indexes for token lookup, account management and expiry cleanup. Identity columns use exact, case-sensitive `C` collation. Apply schema changes through your migrations, not runtime `create-drop`. -## Configure +## Use the lifecycle -Ogiri is opt-in. Supply a `UserDetailsService` or `SubjectStatusChecker`, then configure a Base64-encoded key containing at least 32 random bytes: +```java +var sessions = new PostgresSessions(dataSource); +var owner = new Subject("customers", "tenant-42", "immutable-account-id"); -```yaml -ogiri: - session: - enabled: true - transport: bearer - token-hash: - current-key-id: primary - keys: - primary: ${OGIRI_TOKEN_HASH_KEY_BASE64} - endpoints: - enabled: true - base-path: /auth +// Only after the application has authenticated and authorized this full identity. +var issued = sessions.issue(owner, "Personal phone"); +String credential = issued.token(); // Deliver explicitly, over TLS. Never log it. + +Optional authenticated = sessions.authenticate(credential); +List devices = sessions.list(owner); +sessions.revoke(owner, issued.session().id()); +sessions.revokeAll(owner); +int removed = sessions.cleanup(500); ``` -Generate key material outside source control, for example: +The default lifetime is **seven days**, with at most **ten live sessions per account**. Configure both explicitly with `new SessionPolicy(Duration.ofHours(12), 5)`. Reaching the cap throws `SessionLimitException`; Ogiri does not silently evict another device. All instances sharing the table must use the same policy. -```bash -openssl rand -base64 32 -``` +`Subject` is the tuple `(realm, tenantId, subjectId)`. Realm is an identity namespace, not an OAuth audience. An empty tenant ID means non-tenanted, never all tenants. Subject IDs must be stable; do not use a mutable email address or login name. The session UUID identifies a device record; it cannot authenticate. The client string is an untrusted display label, not a device identifier or authorization scope. + +## Integrate with Spring Security -When the application defines a `SecurityFilterChain`, apply `OgiriHttpConfigurer` to that same chain so authentication and authorization remain together. +Provide the native introspector in the security chain your application already owns: -## Use +```java +@Bean +OpaqueTokenIntrospector introspector(PostgresSessions sessions, AccountDirectory accounts) { + return new OgiriOpaqueTokenIntrospector(sessions, subject -> + accounts.loadSecurityUser(subject.realm(), subject.tenantId(), subject.subjectId())); +} +``` -```kotlin -val subject = OgiriSessions.subject("users", "opaque-user-id", "tenant-a") -val client = OgiriSessions.client("browser-id", "Work laptop") +`AccountDirectory` is your application's existing account adapter, returning Spring `UserDetails`. It must validate the entire identity and any permitted realm/tenant context. Ogiri checks account status and uses its current authorities on every authenticated request; it never assumes that an account ID is a username. -val issued = sessions.issue(subject, client) -val authenticated = sessions.authenticate(issued.credential.encoded(codec)) -sessions.revoke(authenticated) +```java +http.oauth2ResourceServer(resource -> + resource.opaqueToken(opaque -> opaque.introspector(introspector))); ``` -The optional endpoint starter provides sign-in, refresh, sign-out, current-session, session-listing, and revocation routes under the configured base path. +Use the native [Spring Security bearer-token pipeline](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/opaque-token.html). Ogiri registers no filters, endpoints, bean auto-configuration or global CSRF rules. Keep your other authentication mechanisms and authorization rules in their existing owner. -## Documentation +The [standalone Spring Boot example](examples/spring-app) demonstrates password sign-in, native bearer authentication, roles, scoped device management and revocation. Its Maven build consumes installed artifacts rather than reactor source dependencies. It runs real HTTP requests against a real PostgreSQL database, including a one-connection pool. The application example rejects duplicate Authorization headers before native bearer parsing and preserves error status through ERROR redispatches. -- [Quickstart](docs/quickstart.md) -- [Authentication and endpoint behavior](docs/authentication.md) -- [Configuration reference](docs/configuration.md) -- [Database integration and custom stores](docs/database.md) -- [Security model](SECURITY.md) -- [Development guide](docs/development.md) +## Operations and consistency -## Build +Each authentication performs one indexed read and no writes. Fixed expiry uses the database statement-start timestamp; activity does not extend it. Issuance reads that timestamp only after acquiring its account lock, so lock waits do not backdate a new session. Admission uses a transaction-scoped PostgreSQL advisory lock, count and insert; revocation is immediate for authoritative reads that begin after commit. Requests already authorized are not retroactively cancelled. -```bash -./gradlew check -``` +Mutations commit before returning. Supply a normal pool with auto-commit connections, not a transaction-bound `DataSource` proxy. Ogiri rejects already enlisted connections, including for reads. Calls are independent of your application's transactions: an outer rollback does not roll back an already committed session mutation. An outer transaction holding a connection therefore needs additional pool capacity. + +Queries have a five-second timeout. Configure connection acquisition, socket timeouts, TLS and pool sizing on the supplied data source. Database availability is required; there is no stale authentication cache. An invalid token returns `Optional.empty()`; storage failures throw `SessionStoreException` and must not be treated as anonymous success. + +Schedule bounded `cleanup(batchSize)` calls in your existing jobs. `SKIP LOCKED` permits multiple workers without a leader lease. Cleanup is not authentication expiry enforcement: expired tokens are rejected even when cleanup is delayed. Stop paging when the returned count is below your page size, and use your job's run budget. + +## Deliberate limits and revisit triggers + +Revisit fixed lifetime when measured re-authentication friction requires renewal or your threat model requires shorter credential exposure. Revisit PostgreSQL-only storage only for a concrete adopter with a different store and equivalent atomicity proofs. Revisit direct SQL or the absence of caching only after production query/latency measurements; do not cache revocation away. Revisit the passive Spring adapter only if repeated consumer code proves a genuinely shared, safe policy rather than application-specific login behaviour. Licensed under Apache-2.0. diff --git a/SECURITY.md b/SECURITY.md index 1e6800c..96dbebd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,81 +1,35 @@ -# Security Policy +# Security boundaries -## Supported versions +Report vulnerabilities privately through GitHub's private vulnerability reporting for this repository when available. Do not post live credentials or exploit details in a public issue. No response-time or security-certification guarantee is made. -| Line | Security fixes | -| --------------- | ------------------------------------------------------------ | -| 4.x | Supported | -| 3.x | Critical fixes only during the published v4 migration window | -| 2.x and earlier | Unsupported | +## What the credential protects -A release line becomes unsupported when the next major version has been generally available for 12 months. The release notes will announce the exact final support date. +Ogiri generates 32 random bytes using the JDK's `SecureRandom`, encoded as a canonical `og1_` token. PostgreSQL stores only the token's SHA-256 digest and non-secret metadata. This design relies on 256 bits of generated entropy; it is **not** a password-hashing scheme and must never be reused for human-chosen secrets. A read-only database leak does not directly disclose usable credentials. Database write access, process compromise and a stolen plaintext token remain outside that protection. -## Private reporting +There is no keyed token-hash secret to distribute or rotate. That removes operational key-ring state, but also removes the separate-server-secret defence against an attacker who can rewrite credential digests. Protect database writes as authentication authority. A bearer token is sufficient to authenticate within the application's accepted identity context. -Do not open a public issue for a suspected vulnerability. +## Application responsibilities -1. Prefer GitHub private vulnerability reporting for `quantipixels/ogiri`. -2. If private reporting is unavailable, email **oluwaseyi@quantipixels.com** with subject `[SECURITY] Ogiri vulnerability`. -3. Include the affected version/commit, affected module, reproduction, impact, and any proposed mitigation. Do not include real credentials, personal data, or production database contents. +Authenticate before calling `issue`. Derive the complete owner from an authenticated principal before listing or revoking devices; never trust an arbitrary request-supplied owner. Validate every realm/tenant/account component in the Spring account loader. Protect management and recovery endpoints, enforce rate limits on sign-in, restrict CORS, configure CSRF for any ambient browser credentials, and use HTTPS. The example's JSON/custom-header sign-in exemption is valid only with its restricted-origin assumptions; it is not a universal CSRF policy. -Response targets: +Use stable account IDs. The adapter checks disabled, locked, account-expired and credentials-expired states at every request, after validating the token. It does not persist account-state changes or erase sessions during an authentication read. Re-enabling an account can therefore make its still-live sessions usable again unless the application explicitly revoked them. Password resets, compromise recovery, account deletion and permanent bans must revoke sessions in the application's identity workflow. Coordinate concurrent sign-in/recovery according to that workflow; Ogiri is not an atomic account-and-session transaction manager. -- Acknowledge within 2 business days. -- Provide an initial severity and remediation plan within 7 days. -- Target a patch within 30 days for confirmed high/critical issues. -- Coordinate disclosure after supported releases and migration guidance are available. +No automatic rotation, refresh-token reuse detection, idle expiry, privilege-step-up or MFA is provided. A stolen token remains usable until its fixed expiry or explicit revocation, subject to current account checks. Choose a lifetime appropriate to the threat model; seven days is a configurable convenience default, not a universal security recommendation. Use a shorter lifetime and re-authentication, or an established identity provider, when stronger lifecycle controls are required. -If the report exposes active exploitation or leaked credentials, state that clearly in the subject and revoke the credentials immediately. +## Storage and failure behaviour -## v4 security contract +Only use a normal PostgreSQL connection pool, not a caller-bound transaction proxy. Reads must hit the authoritative primary, not a lagging replica. Already enlisted connections are rejected; each mutation commits independently before a credential or success result is returned. A connection lost during commit can leave the outcome unknown: the library throws and does not return a credential. Such an orphaned record can consume a slot until explicit revocation or expiry. No automatic retry pretends to resolve ambiguous commits. -### Credential and storage model +Authentication does not write activity timestamps or cache a positive result. Revocation removes the row, so later primary reads cannot resurrect it from a cache. In-flight statements or requests begun before revocation may finish. Expiry is evaluated at statement start; it does not interrupt a statement already executing. Advisory-lock hash collisions can serialize unrelated accounts but do not merge identities: every account-management SQL statement separately compares all three identity columns. -- Session credentials are opaque `selector.verifier` values. The selector is an indexed, non-secret routing identifier; the 256-bit verifier is secret. -- Stores persist only keyed HMAC digests. `IssuedSession.credential` is separate from immutable `StoredSession` and is the only core result containing plaintext. -- HMAC keys are externally supplied, at least 256 bits, identified by key ID, and may overlap during rotation. Password encoders are not used for bearer credentials. -- The authoritative `SessionStore` is consulted for authentication and revocation. Cache availability or stale cache data must never restore a revoked session. +Pool acquisition and network timeouts are host configuration. SQL statements use a five-second query timeout. Database and account-directory outages fail closed and remain distinguishable from invalid tokens; do not convert availability failures into fabricated successful principals. -### Rotation and revocation +## Credential handling -- A session accepts the current verifier and, only during compatibility grace, one previous verifier. -- `previousValidUntil` is fixed by the successful compare-and-rotate command. Activity updates cannot extend it. -- Rotation is optimistic compare-and-swap. Exactly one concurrent successor commits; losing callers receive a conflict and never receive a dead credential. -- Reuse of the known previous verifier after its fixed deadline revokes the session family. -- Logout binds to the stable authenticated session ID. User-wide and account-state revocation are immediate at the authoritative store. +Deliver `IssuedSession.token()` explicitly once and exclude it from logs, analytics, exception messages and default JSON serialization. `IssuedSession.toString()` is redacted, but its string accessor is deliberately sensitive. `Session` and the adapter's principal contain no token or digest. Java strings and Spring's native bearer authentication may retain credential bytes in memory; no complete memory-erasure guarantee is claimed. Treat heap dumps as secrets. -### Spring Security and transport +Never send tokens in URLs or user-controlled client labels. Use secure client storage appropriate to the application. Browser HttpOnly session cookies require a different transport and CSRF arrangement; prefer Spring Session for that use case rather than embedding this bearer token in an ad hoc cookie wrapper. -- Authentication and authorization must be composed in one selected `SecurityFilterChain`. -- The optional starter chain permits only explicit `ogiri.session.public-paths` and ends with `anyRequest().authenticated()`. -- Bearer is the default v4 transport. Cookie and devise-token-auth compatibility are explicit, mutually exclusive profiles. -- Cookie mode uses `HttpOnly`, `Secure`, `SameSite`, aligned path/expiry, and CSRF protection by default. `SameSite=None` without `Secure` is rejected. -- Credential and authentication-error responses use `Cache-Control: no-store`; bearer failures include `WWW-Authenticate` metadata. -- Proxies must redact `Authorization`, `access-token`, cookies, and request bodies containing passwords. TLS is required outside isolated local development. +## Verification limits -### Subject authority - -- Sessions bind to `realm + optional tenant + opaque String subject ID`; mutable email addresses are not session identifiers. -- A `SubjectStatusChecker` runs on every session authentication. Disabled, locked, expired, or credential-expired subjects are denied. -- Applications should load sensitive roles live or include an application security version in their status policy. - -### Operations - -- Cleanup uses bounded pages. Clustered scheduling requires an `OgiriJobLease`; the JPA adapter provides a database lease. -- Distributed rate limiting is optional. The Redis adapter hashes identifier keys, ignores forwarded-address headers by default, and returns `429` with `Retry-After`. -- Session events are immutable, emitted only after a store command returns successfully, and exclude credentials. Micrometer tags are bounded-cardinality. -- Redis deployments must use authentication, least-privilege ACLs, TLS where traffic leaves a trusted host, and a deployment-specific key prefix. - -## Verification and release controls - -Pull requests and releases run deterministic state-machine tests, full-chain MockMvc tests, JPA transaction/concurrency tests, Java/Kotlin consumer compilation, dependency analysis, CodeQL, coverage gates, and signed publication checks. A GitHub release is created only after every Maven Central module resolves from the immutable tag. - -Run the local security checks with: - -```bash -./gradlew check dependencyCheckAnalyze -``` - -## Disclosure - -For a confirmed vulnerability, maintainers will prepare supported-line patches, migration guidance, a GitHub security advisory, and a CVE when appropriate before public disclosure. Published advisories will identify affected versions and whether session invalidation or key rotation is required. +PostgreSQL behavioural tests, native HTTP consumer tests, static analysis and selected mutation probes are evidence, not proof of all interleavings, production performance or penetration-test coverage. Independent security review is still appropriate before production adoption. diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 41126fe..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,77 +0,0 @@ -import com.diffplug.gradle.spotless.SpotlessExtension - -// Apply centralized version management -apply(from = "gradle/version.gradle.kts") - -// Consolidate Kotlin plugin in root to avoid loading multiple times in subprojects -plugins { - kotlin("jvm") version libs.versions.kotlin.get() apply false - kotlin("plugin.spring") version libs.versions.kotlin.get() apply false - kotlin("plugin.jpa") version libs.versions.kotlin.get() apply false - id("com.diffplug.spotless") version libs.versions.spotless.get() apply false -} - -allprojects { - repositories { mavenCentral() } - - apply(plugin = "com.diffplug.spotless") - - configure { - kotlin { - target("src/**/*.kt", "src/**/*.kts") - targetExclude("**/build.gradle.kts", "**/settings.gradle.kts", "**/spotless.license.kt") - licenseHeaderFile(rootProject.file("spotless.license.kt")) - ktfmt("0.43") - trimTrailingWhitespace() - endWithNewline() - } - java { - target("src/**/*.java") - targetExclude("**/build/**") - licenseHeaderFile(rootProject.file("spotless.license.kt")) - googleJavaFormat("1.28.0") - trimTrailingWhitespace() - endWithNewline() - } - kotlinGradle { - target("*.gradle.kts") - ktfmt("0.43") - trimTrailingWhitespace() - endWithNewline() - } - sql { - target("src/**/*.sql") - dbeaver() - trimTrailingWhitespace() - endWithNewline() - } - format("toml") { - target("**/*.toml") - targetExclude("**/build/**") - trimTrailingWhitespace() - endWithNewline() - } - format("misc") { - target(".gitignore", ".gitattributes", "**/*.md", "**/*.yaml", "**/*.yml") - targetExclude( - "**/build/**", - "**/node_modules/**", - ".claude/**", - ".ai-toolkit/**", - "plans/**", - "thoughts/**", - "**/pnpm-lock.yaml", - "pnpm-workspace.yaml", - "sample/sample-react/**") - prettier() - trimTrailingWhitespace() - endWithNewline() - } - } -} - -tasks.register("setupDev") { - description = "Install lefthook git hooks for development workflow" - group = "Development" - commandLine("lefthook", "install") -} diff --git a/config/dependency-check-suppressions.xml b/config/dependency-check-suppressions.xml deleted file mode 100644 index 3af08e8..0000000 --- a/config/dependency-check-suppressions.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - ^pkg:maven/org\.jetbrains\.kotlin/kotlin-(stdlib|reflect)@.*$ - CVE-2026-53914 - CVE-2020-29582 - - diff --git a/docs/.well-known/security.txt b/docs/.well-known/security.txt deleted file mode 100644 index 0a02aeb..0000000 --- a/docs/.well-known/security.txt +++ /dev/null @@ -1,6 +0,0 @@ -Contact: https://github.com/quantipixels/ogiri/security/advisories/new -Contact: mailto:oluwaseyi@quantipixels.com -Policy: https://github.com/quantipixels/ogiri/blob/ori/SECURITY.md -Preferred-Languages: en -Canonical: https://quantipixels.github.io/ogiri/.well-known/security.txt -Expires: 2027-07-11T00:00:00Z diff --git a/docs/adr/0001-selector-verifier-session-core.md b/docs/adr/0001-selector-verifier-session-core.md deleted file mode 100644 index 739c6e1..0000000 --- a/docs/adr/0001-selector-verifier-session-core.md +++ /dev/null @@ -1,29 +0,0 @@ -# ADR 0001: Selector/verifier session core - -- Status: Accepted -- Date: 2026-07-11 - -## Context - -The v3 token model coupled servlet response mutation, user lookup, password encoding, mutable persistence entities, rotation history, caches, and generic child credentials. Rotation grace could move with unrelated updates, concurrent rotation could return a losing credential, and independent Spring Security chains did not compose authorization. - -The project could preserve the v3 wire/schema shape with additional guards, or make a deliberate breaking session model. - -## Decision - -Version 4 uses a pure session module with opaque selector/verifier credentials and an atomic `SessionStore` interface. - -- The public identity is realm + optional tenant + opaque String subject ID. -- A stable session ID is separate from the credential selector. -- Stores contain keyed verifier digests, never issued plaintext. -- One current and one previous credential version are allowed; the previous deadline is immutable. -- Issue/admission, compare-and-rotate, and revocation are atomic store commands. -- Spring Security conversion/provider and HTTP response writing are adapters outside the state machine. -- Authentication and authorization are configured in one selected chain. -- Bearer is the default profile; cookie and devise-token-auth compatibility are explicit. -- The database is authoritative for revocation. Caches are optimizations only. -- V3 sessions are not silently interpreted as v4 sessions. Migration requires explicit reissue or a separately reviewed dual-read adapter. - -## Consequences - -The change intentionally breaks v3 persistence and wire assumptions for new integrations. Applications gain deterministic state-machine tests, stable logout/session management, fixed replay bounds, Java-friendly factories, and storage adapters that can prove one atomic contract. JDBC and cache adapters are not promoted to the v4 support matrix until they satisfy that contract. diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index e4ba54a..0000000 --- a/docs/authentication.md +++ /dev/null @@ -1,58 +0,0 @@ -# Authentication - -## Request flow - -1. A Spring Security `AuthenticationConverter` extracts exactly one configured credential transport. -2. `OgiriSessionAuthenticationProvider` decodes the opaque selector, loads an immutable committed session snapshot, constant-time verifies the keyed digest, checks expiry/revocation, and runs `SubjectStatusChecker`. -3. The provider creates `OgiriSessionPrincipal` containing stable session ID, subject, realm, tenant, client, family, and version. It never retains the verifier. -4. Authorization executes in the same selected `SecurityFilterChain`. - -Missing credentials do not authenticate a request; authorization still rejects protected routes. Malformed or invalid credentials produce a stable RFC 9457 response with `401`, `WWW-Authenticate`, and `Cache-Control: no-store`. - -In cookie mode, safe requests emit a readable `XSRF-TOKEN` cookie. Browser clients must copy its raw value into the `X-XSRF-TOKEN` header for unsafe requests. The session cookie remains `HttpOnly`. - -## Issuance - -The optional sign-in endpoint first delegates username/password or another credential to the application's `AuthenticationManager`. Only an already-authenticated Spring `Authentication` is converted to a session subject. The store transaction returns before the HTTP adapter writes a header or cookie, so rollback and commit failures cannot leak an unusable credential. - -Applications that own endpoints call the same seam: - -```kotlin -val authenticated = authenticationManager.authenticate(loginRequest) -val subject = subjectResolver.resolve(authenticated) -val issued = sessions.issue(subject, clientContext) -responseWriter.writeCredential(response, issued) -``` - -## Rotation - -Rotation is a compare-and-swap command over stable session ID, expected version, and expected current digest. A successful command: - -- moves the current digest to the single previous slot; -- sets one immutable `previousValidUntil`; -- stores the successor digest; -- increments the record version; and -- returns the successor verifier only to the winning caller. - -A previous verifier may authenticate strictly before its fixed deadline but cannot rotate. At the deadline it fails and triggers reuse revocation. Activity updates modify only `lastUsedAt`; they cannot move credential deadlines. - -## Logout and session management - -Logout revokes the stable session ID carried by `OgiriSessionPrincipal`, not a re-comparison against whichever digest is currently stored. It is idempotent and emits no replacement credential. Cookie mode expires the configured cookie with matching attributes. - -The endpoint starter can list active sessions, revoke one owned session, revoke all other sessions, or revoke all sessions through `SessionManager`. Responses expose labels and timestamps, never digests or verifiers. - -## Account state - -`SubjectStatusChecker` runs during issuance and every session authentication. The default adapter uses Spring Security's `AccountStatusUserDetailsChecker`, covering disabled, locked, account-expired, and credentials-expired users. Applications with UUID/opaque IDs, multiple realms, password security versions, or external identity providers should supply their own checker. - -## Errors - -| Condition | Status | Stable code | -| --------------------------------- | ------: | ------------------------------------------ | -| Malformed request/credential | 400/401 | `malformed_request` / `invalid_credential` | -| Subject not allowed | 403 | `subject_unavailable` | -| Concurrent rotation/session limit | 409 | `session_conflict` / `session_limit` | -| Validation failure | 422 | `invalid_request` | -| Distributed throttle | 429 | `rate_limit_exceeded` | -| Unexpected failure | 500 | No internal exception message is exposed | diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 120000 index 04c99a5..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 33c28f6..0000000 --- a/docs/configuration.md +++ /dev/null @@ -1,87 +0,0 @@ -# Configuration - -Ogiri v4 is opt-in with `ogiri.session.enabled=true`. Invalid security combinations fail application startup. - -```yaml -ogiri: - session: - enabled: true - realm: users - transport: bearer # bearer, cookie, or dta-compat - lifetime: 14d - previous-version-grace: 5s - maximum-active-sessions: 10 - evict-oldest-when-full: true - maximum-credential-bytes: 256 - public-paths: - - /auth/sign-in - - /actuator/health - token-hash: - current-key-id: primary - keys: - primary: ${OGIRI_TOKEN_HASH_KEY_BASE64} - endpoints: - enabled: true - base-path: /auth - cleanup: - enabled: false - interval: 6h - lease: 30m - max-run-duration: 5m - batch-size: 500 - rate-limit: - enabled: false - sign-in-permits: 10 - window: 1m - key-prefix: "my-app:prod:ogiri:rate-limit:" -``` - -## Token hashing - -`token-hash.keys` values are standard Base64-encoded keys containing at least 32 random bytes. The key selected by `current-key-id` signs new verifier digests. Existing sessions retain their key ID, so old keys remain readable during rotation. - -Rotation procedure: - -1. Add a new key while retaining the old key. -2. Change `current-key-id` to the new ID on every node. -3. Wait for old sessions to expire or revoke them. -4. Remove the old key. - -A password encoder is not a token hasher. Credential authentication remains owned by the application's `AuthenticationManager`; Ogiri uses HMAC-SHA-256 only for random session verifiers. - -## Transport profiles - -### Bearer - -The default v4 profile accepts exactly one case-insensitive `Bearer` scheme containing a canonical opaque `selector.verifier` value. It emits credentials only through `Authorization` and adds `Cache-Control: no-store`. - -### Cookie - -Cookie mode accepts only the configured cookie and emits no readable token header or body field. Defaults: - -- name `__Host-ogiri-session` -- `Secure=true` -- `HttpOnly=true` -- `SameSite=Strict` -- `Path=/` -- CSRF enabled with Spring Security's cookie token repository - -`SameSite=None` requires `Secure=true`; `__Host-` requires `Secure=true` and `Path=/`. - -### DTA compatibility - -`dta-compat` isolates the legacy `access-token`, `client`, and `uid` headers. It is not the default and cannot be combined with bearer or cookie output. - -## Security chain - -When the application owns a `SecurityFilterChain`, apply `OgiriHttpConfigurer` to that same chain and define authorization there. When no chain exists, the optional starter permits `public-paths` and protects every other request. - -The optional endpoint starter uses `endpoints.base-path` as its route prefix. The value must be a canonical absolute literal path such as `/auth` or `/api/session-auth`; root, trailing slashes, duplicate separators, wildcards, variables, queries, and fragments are rejected. When changing it, update `public-paths`, gateway routes, clients, and application-owned authorization matchers to the same prefix. - -## Cleanup - -Cleanup is disabled by default. Enabling it requires both `SessionManager` and a cluster-safe `OgiriJobLease`; otherwise startup fails. The JPA adapter safely initializes the lease row under concurrent first use, and the active owner renews the lease between independently committed pages. `max-run-duration` must be positive and shorter than `lease`; reaching it leaves the remaining backlog for a later scheduled run. Size the lease above the worst expected duration of one page, because work already executing cannot be interrupted by lease renewal. - -## Distributed rate limiting - -Enabling rate limiting requires an `OgiriRateLimiter`. With `ogiri-redis`, Ogiri hashes IP/normalized-identifier keys and uses one atomic Redis script per bucket. Forwarded headers are not trusted by default. Rejections use RFC 9457 problem details, status `429`, and `Retry-After`. diff --git a/docs/database.md b/docs/database.md deleted file mode 100644 index 1a2a75a..0000000 --- a/docs/database.md +++ /dev/null @@ -1,56 +0,0 @@ -# Database Integration - -## v4 support matrix - -| Store | v4 status | Continuously exercised | -| ---------------------------------------- | ------------------------------------- | --------------------------------------------- | -| JPA/Hibernate with H2 | Supported for tests/local development | Yes | -| JPA/Hibernate with PostgreSQL | Supported production target | Schema and contract gate required for release | -| Legacy JDBC token repository | v3 compatibility only | Legacy tests only | -| Redis/Caffeine/Spring Cache token lookup | Not in the v4 correctness path | Legacy tests only | - -The narrower matrix is intentional. The v3 JDBC adapter's identifier/dialect and concurrency contract is not promoted to v4 until it implements the same atomic `SessionStore` behavior against every claimed database. - -## Canonical schema - -`ogiri-jpa` ships `db/migration/V4__create_ogiri_sessions.sql`. Applications using Flyway discover the migration from the dependency. The schema contains: - -- stable `session_id` and indexed non-secret `selector`; -- `realm`, optional `tenant_id`, and opaque `subject_id`; -- current digest/key ID and one previous digest/key ID with fixed `previous_valid_until`; -- optimistic `record_version`, token `family_id`, expiry/activity timestamps, and revocation reason; -- subject-lock rows for atomic maximum-session admission; and -- job-lease rows for clustered cleanup ownership. - -All runtime `Instant` values use UTC. Configure Hibernate with `hibernate.jdbc.time_zone=UTC`. PostgreSQL deployments should retain timezone-aware columns. Validate the migration in CI with `spring.jpa.hibernate.ddl-auto=validate`; do not use `update` in production. - -## Default JPA store - -Adding `ogiri-jpa` registers `OgiriJpaSessionStore` unless the application provides another `SessionStore`. No token entity subclass or token factory is required. - -Atomic commands: - -- `create` serializes admission per `realm + tenant + subject` and applies the APP-session maximum in the same transaction; -- `compareAndRotate` updates only the expected record version and digest; -- `revoke` and `revokeAll` operate on stable session IDs/subjects; -- `deleteExpiredPage` locks and deletes at most the requested page size; and -- clustered cleanup initializes its lease row safely under concurrent first acquisition, renews ownership between pages, and uses owner-conditional release. - -The store returns immutable `StoredSession` snapshots. Plaintext verifiers are structurally absent from the entity and migration. - -## Custom stores - -A custom adapter implements `SessionStore`. Correctness requirements are part of the interface: - -1. `create` must atomically enforce maximum active sessions. -2. `compareAndRotate` must commit at most one successor for an expected version/digest. -3. `recordUse` must never change credential digests or `previousValidUntil`. -4. Revocation must be immediately observable by subsequent authoritative reads. -5. Cleanup deletes a bounded page per call. -6. Returned objects are immutable committed snapshots with no plaintext secret. - -Run the public `ogiri-test` fixtures and the same concurrency/revocation scenarios before claiming support for a new database. - -## Cache and Redis - -A v4 authentication request reads the authoritative store. Cache modules cannot restore a revoked session and are not used to hold application entities. The Redis rate limiter stores only hashed bucket keys and counters under an application/realm prefix; it does not store session verifiers or polymorphic session objects. diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 9a849c0..0000000 --- a/docs/development.md +++ /dev/null @@ -1,258 +0,0 @@ -# Development Guide - -Guide for contributors working on the ogiri codebase. - -## Prerequisites - -- Java 17+ -- Kotlin 2.0.x -- Gradle 8.x -- PostgreSQL (for running samples) - -## Build Commands - -| Command | Description | -| ---------------------------- | --------------------------------- | -| `./gradlew build` | Compile all modules and run tests | -| `./gradlew test` | Run test suite only | -| `./gradlew :ogiri-core:test` | Run core library tests only | -| `./gradlew clean` | Remove build artifacts | -| `./gradlew spotlessApply` | Auto-format code | -| `./gradlew spotlessCheck` | Verify formatting | - -### Running Samples - -```bash -./gradlew :sample:sample-kotlin:bootRun # Kotlin sample -./gradlew :sample:sample-java:bootRun # Java sample -``` - -Requires PostgreSQL on `localhost:5432`. See [sample/README.md](../sample/README.md) for setup. - -## Project Structure - -```text -ogiri/ -├── ogiri-core/ # Core library: interfaces, filter, token service -│ ├── src/main/kotlin/com/quantipixels/ogiri/security/ -│ │ ├── core/ # AuthHeader, JsonCodec, exceptions -│ │ ├── tokens/ # OgiriTokenService, OgiriTokenRepository, OgiriToken -│ │ ├── web/ # OgiriTokenAuthenticationFilter -│ │ ├── spi/ # OgiriUserDirectory, OgiriAuditHook, OgiriRateLimitHook -│ │ ├── helpers/ # AuthenticationBypassDecider, SecurityHelpers -│ │ ├── routes/ # OgiriRouteRegistry, OgiriRoute -│ │ └── config/ # OgiriSecurityAutoConfiguration -│ ├── src/test/kotlin/ # JUnit 5 tests -│ └── src/main/resources/ogiri/db/ # Bundled SQL schemas (PostgreSQL, MySQL, H2) -├── ogiri-jpa/ # JPA adapter: OgiriBaseTokenEntity, JPA auto-configuration -├── ogiri-jdbc/ # JDBC adapter: OgiriBaseTokenRow, OgiriJdbcTokenRepository -├── ogiri-caffeine/ # Caffeine token lookup cache module -├── ogiri-redis/ # Redis token lookup cache module -├── sample/ -│ ├── sample-java/ # Pure Java example (port 48080) -│ ├── sample-kotlin/ # Kotlin example (port 48081) -│ └── sample-react/ # React + TypeScript example (port 5173) -├── docs/ # Documentation (MkDocs) -└── .github/workflows/ # CI/CD pipelines -``` - -## Code Style - -- **Indentation:** 2 spaces -- **Nullability:** Explicit with `?`; avoid `!!` outside tests -- **Naming:** PascalCase for classes, camelCase for functions -- **Tests:** Backticked names: `` `should rotate token outside batch window` `` -- **Formatting:** Run `spotlessApply` before committing - -## Testing - -### Running Tests - -```bash -./gradlew test # All tests -./gradlew :ogiri-core:test # Core only -``` - -Coverage report: `ogiri-core/build/reports/jacoco/test/html/index.html` - -### Test Guidelines - -- Use JUnit 5 with Spring test utilities -- Place tests in `src/test/kotlin//Test.kt` -- Use in-memory fakes (e.g., `InMemoryTokenRepository`) -- When modifying token logic, add `AuthHeader` serialization tests -- When changing schemas, update persistence tests - -### Current Coverage - -| Component | Coverage | -| ------------------------------ | -------- | -| AuthenticationBypassDecider | 100% | -| AuthHeader | 90% | -| OgiriTokenAuthenticationFilter | 70% | -| OgiriTokenService (sub-tokens) | 25% | -| OgiriSecurityAutoConfiguration | 0% | - -## Git Hooks - -Install hooks for code quality enforcement: - -```bash -lefthook install -``` - -- **Pre-commit:** Runs `spotlessCheck` -- **Pre-push:** Runs full build - -## Commit Guidelines - -Use [Conventional Commits](https://www.conventionalcommits.org/): - -```text -feat: add chat sub-token renewal -fix: adjust expiry parsing -refactor: extract validation logic -docs: update configuration guide -test: add rotation edge cases -chore: bump version to 1.0.2 -``` - -## Pull Request Process - -1. Create feature branch from `main` -2. Make changes with tests -3. Run `./gradlew build spotlessCheck` -4. Push and create PR -5. Link related issues -6. Wait for CI and review - -## Version Management - -Version is defined in `settings.gradle.kts`: - -```kotlin -val projectVersion = System.getenv("RELEASE_VERSION") ?: "3.0.1" -``` - -### Override Version - -```bash -RELEASE_VERSION=1.0.2 ./gradlew build -``` - -### Bump Version - -```bash -./gradlew bumpVersion -PnewVersion=1.0.2 -``` - -## Release Process - -### Automated Release (Recommended) - -Push a git tag to trigger the release workflow: - -```bash -# 1. Update version in .ogiri-version -# 2. Update CHANGELOG.md -# 3. Commit changes -RELEASE_VERSION="$(tr -d '[:space:]' < .ogiri-version)" -git add .ogiri-version CHANGELOG.md -git commit -m "chore: bump version to ${RELEASE_VERSION}" - -# 4. Create and push tag -git tag "v${RELEASE_VERSION}" -git push origin ori "v${RELEASE_VERSION}" -``` - -GitHub Actions will: - -- Build and test -- Sign artifacts with GPG -- Publish to Maven Central -- Create GitHub release - -### CI/CD Workflows - -| Workflow | Trigger | Purpose | -| -------------- | ------------- | ------------------------ | -| `build.yml` | All pushes | Compile modules | -| `test.yml` | All pushes | Run tests with coverage | -| `lint.yml` | All pushes | Verify formatting | -| `release.yml` | Tag `v*.*.*` | Publish to Maven Central | -| `snapshot.yml` | Push to `ori` | Deploy snapshots | - -### Required Secrets - -Configure in GitHub repository settings: - -| Secret | Purpose | -| ----------------- | ----------------------------- | -| `OSSRH_USERNAME` | Central Portal token username | -| `OSSRH_PASSWORD` | Central Portal token password | -| `GPG_PASSPHRASE` | GPG passphrase | -| `GPG_PRIVATE_KEY` | ASCII-armored GPG private key | - -Export GPG key: - -```bash -export KEY_ID=your_gpg_key_id -gpg --armor --export-secret-keys "$KEY_ID" -``` - -### Manual Release (Not Recommended) - -```bash -export RELEASE_VERSION="$(tr -d '[:space:]' < .ogiri-version)" -export OSSRH_USERNAME=your_portal_token_username -export OSSRH_PASSWORD=your_portal_token_password -export KEY_ID=your_gpg_key_id -export GPG_PRIVATE_KEY="$(gpg --armor --export-secret-keys "$KEY_ID")" -export GPG_PASSPHRASE=your_gpg_passphrase - -./gradlew clean check publish -authorization="$(printf '%s:%s' "$OSSRH_USERNAME" "$OSSRH_PASSWORD" | base64 | tr -d '\n')" -curl \ - --fail \ - --request POST \ - --header "Authorization: Bearer $authorization" \ - "https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/com.quantipixels?publishing_type=automatic" -``` - -### Release Checklist - -- [ ] Tests pass: `./gradlew test` -- [ ] Formatting verified: `./gradlew spotlessCheck` -- [ ] `CHANGELOG.md` updated -- [ ] Version updated in `.ogiri-version` -- [ ] Tag created and pushed -- [ ] CI workflow completed - -## Common Tasks - -### Adding a Sub-Token Type - -1. Implement `OgiriSubTokenRegistration` bean -2. Define `name`, `clientIdFor()`, `expiry()`, `includeByDefault` -3. Add tests in `TokenServiceSubTokenTest` -4. Document in `docs/sub-tokens.md` - -### Modifying Token Rotation - -1. Update `OgiriTokenService.rotateTokensIfNeeded()` -2. Add tests in `OgiriTokenAuthenticationFilterTest` -3. Update `docs/configuration.md` - -### Extending Token Entity - -1. Create class extending `OgiriBaseToken` -2. Implement `OgiriTokenRepository` -3. Provide custom `OgiriTokenService` bean -4. Set `ogiri.security.register-filter=false` - -## Security - -- Never log raw tokens -- Use `SecurityServiceException` for auth errors -- Use `IdentifierPolicy` for validation -- Register public routes to prevent lockouts diff --git a/docs/future-roadmap.md b/docs/future-roadmap.md deleted file mode 100644 index 471c3bf..0000000 --- a/docs/future-roadmap.md +++ /dev/null @@ -1,425 +0,0 @@ -# Future Roadmap - -Items identified during security audit (2026-02-08) and architecture review (2026-02-27) that are -worth doing but not urgent. - ---- - -## Priority 1 — Fill Known Gaps in Existing Seams - -These complete the persistence, testing, and authorization stories that are already partially built. - ---- - -## R14: Enforce Authenticated Codecov Uploads - -Client and backend coverage uploads are optional until the repository has a supported Codecov -authentication method. Configure either a `CODECOV_TOKEN` GitHub Actions secret or Codecov OIDC. -Then restore `fail_ci_if_error: true` in both coverage workflows. - -Completion requires a current-head pull request run in which the backend and client coverage files -upload successfully and Codecov reports both flags. - -**Impact**: Restores coverage publication as an enforced CI check -**Effort**: Low — repository authentication and two workflow settings - ---- - -## R15: Activate and Prove Maven Central Publishing - -Configure the Central Portal token and GPG signing secrets documented in `docs/development.md`. -Then publish the next intended release through `release.yml`. Confirm that every module listed by -the workflow resolves from Maven Central with the release tag version. - -Do not test this process with a disposable release version. Maven Central releases are immutable. -Use local publication checks before the real tag, then use the workflow's resolution checks as the -production proof. - -**Impact**: Makes signed Maven Central releases operational and repeatable -**Effort**: Low-Medium — repository secrets, Central Portal access, and one observed release - ---- - -## R1: ogiri-jpa Default Repository - -Provide an intermediate JPA repository with optimized `@Query` annotations so users don't have to -write boilerplate overrides for `countByUserId`, `deleteByExpiryAtBefore`, etc. - -```kotlin -@NoRepositoryBean -interface OgiriJpaTokenRepository : - JpaRepository, OgiriTokenRepository { - - @Modifying @Query("DELETE FROM #{#entityName} t WHERE t.expiryAt < ?1") - override fun deleteByExpiryAtBefore(cutoff: Instant): Int - - @Query("SELECT COUNT(t) FROM #{#entityName} t WHERE t.userId = ?1") - override fun countByUserId(userId: Long): Long -} -``` - -Users would extend `OgiriJpaTokenRepository` instead of both `JpaRepository` and -`OgiriTokenRepository` separately. Eliminates boilerplate and ensures optimal queries. - -**Impact**: Reduces user-side code, prevents N+1 mistakes -**Effort**: Low — single interface file in ogiri-jpa module - ---- - -## R2: Test Coverage Gaps - -Remaining gaps not yet addressed: - -- Edge cases in token rotation during concurrent requests -- Cleanup job behavior under load - -**Impact**: Prevents regressions as the codebase evolves -**Effort**: Medium - ---- - -## R3: `ogiri-mongodb` Persistence Module - -The README and documentation list MongoDB as a supported backend, but no first-party module exists. -Every MongoDB user must implement `OgiriTokenRepository` from scratch. - -A new `ogiri-mongodb` module would mirror `ogiri-jpa`: - -- `OgiriBaseTokenDocument` — `@Document`-annotated base class with all standard token fields - pre-mapped -- `OgiriMongoTokenRepository` — extends `MongoRepository` and - `OgiriTokenRepository` with Spring Data-compatible query method names -- `OgiriMongoAutoConfiguration` — auto-configures when `spring-data-mongodb` is on the classpath - -**Impact**: Closes the gap between documented and actual MongoDB support; reduces integration -friction for MongoDB users -**Effort**: Medium — mirrors the JPA/JDBC pattern; main work is mapping field names to MongoDB -document conventions - ---- - -## R4: `ogiri-spring-test` Testing Utilities Module - -Consumer test suites have no first-party helpers. Common patterns (seeding test tokens without BCrypt -overhead, creating an authenticated `SecurityContext`, writing controller slice tests) are -reimplemented per project. - -A published `ogiri-spring-test` artifact would contain: - -- `@OgiriTest` meta-annotation — boots only the token/security slice without full application context -- `OgiriTestTokenBuilder` — fluent builder that populates a token with a plaintext `tokenHash` - bypass, sidestepping BCrypt in tests -- `MockOgiriUser` — pre-authenticated `UsernamePasswordAuthenticationToken` for controller slice - tests -- `InMemoryTokenRepository` — extracted from core test sources into a published, reusable class - -```kotlin -@OgiriTest -class LoginControllerTest { - @Test - fun `authenticated request reaches handler`() { - val token = OgiriTestTokenBuilder().forUser(42L).withClient("web").build() - // ... - } -} -``` - -**Impact**: Substantially reduces test boilerplate for every consumer project; makes the right thing -easy -**Effort**: Medium — core classes already exist in `ogiri-core` test sources; packaging and polish -required - ---- - -## R5: Token Scopes / Capability Grants - -The current authorization model is binary — a token is valid or it is not. Sub-tokens provide -isolation but no graduated permissions. This limits Ogiri's use as an API key issuer. - -Design: - -- `scopes: Set` field on `OgiriToken` (nullable for backward compatibility) -- `OgiriScopesValidator` SPI — called from `OgiriTokenAuthenticationFilter` after successful token - validation; populates Spring Security `GrantedAuthority` -- `@RequiresScope("write:orders")` annotation for controller methods -- `OgiriSubTokenRegistration` gains a `scopes(): Set` override point so sub-tokens can carry - narrower permissions than their parent - -```kotlin -@GetMapping("/api/admin/users") -@RequiresScope("admin:read") -fun listUsers(): List = ... -``` - -**Impact**: Unlocks API key issuance, fine-grained authorization, and service-to-service scoped -access -**Effort**: Medium-High — touches `OgiriToken`, the filter, the SPI surface, and requires a migration -path for existing tokens - ---- - -## R6: `ogiri-bucket4j` Rate Limiting Module - -`OgiriRateLimitHook` ships with no implementations. Every team writes its own Bucket4j or Redis -sliding-window code. - -A new `ogiri-bucket4j` module would auto-configure a `Bucket4jOgiriRateLimitHook` when Bucket4j is -on the classpath and `ogiri.rate-limit.enabled=true`: - -```yaml -ogiri: - rate-limit: - enabled: true - login: - capacity: 10 - refill-tokens: 10 - refill-period-seconds: 60 - token-creation: - capacity: 5 - refill-tokens: 5 - refill-period-seconds: 60 -``` - -Separate limit buckets per operation (`beforeLogin`, `beforeTokenCreation`, -`beforeSubTokenRenewal`), keyed by IP by default with a pluggable key extractor. - -**Impact**: Removes the most common "what should I put in the rate limit hook?" question; makes -secure defaults more achievable -**Effort**: Medium — Bucket4j integration is straightforward; main design work is key extraction and -configuration model - ---- - -## R13: `ogiri-spring-starter` Convenience Starter - -Adopters currently need to declare multiple coordinates: - -```kotlin -implementation("com.quantipixels.ogiri:ogiri-core:VERSION") -implementation("com.quantipixels.ogiri:ogiri-jpa:VERSION") // or ogiri-jdbc -implementation("com.quantipixels.ogiri:ogiri-caffeine:VERSION") // or ogiri-redis -``` - -A single `ogiri-spring-starter` artifact would serve as a curated entry point following Spring Boot -starter conventions: - -```kotlin -implementation("com.quantipixels.ogiri:ogiri-spring-starter:VERSION") -``` - -- Pulls in `ogiri-core` unconditionally -- Declares `ogiri-jpa`, `ogiri-jdbc`, `ogiri-caffeine`, and `ogiri-redis` as optional - `compileOnly`/`runtimeOnly` dependencies — the appropriate modules activate via their existing - `@ConditionalOnClass` auto-configurations when the backing library (Hibernate, Caffeine, etc.) is - present -- Ships no new code — purely a dependency aggregator with a `AutoConfiguration.imports` entry - -Individual modules remain independently consumable for users who need precise control over their -dependency graph. - -**Impact**: Reduces onboarding to a single dependency declaration; matches the mental model new -adopters expect from the Spring Boot ecosystem -**Effort**: Low — a new Gradle module with curated dependency declarations and no source code - ---- - -## Priority 2 — Expand the Perimeter - -These extend Ogiri into adjacent use-cases. Each requires more design work than Priority 1 items. - ---- - -## R7: Token Introspection Endpoint - -In a microservice architecture, downstream services need to validate tokens issued by the auth -service without sharing the token store. - -An `OgiriIntrospectionController`, auto-configured behind `ogiri.introspection.enabled=true`, would -expose: - -``` -POST /api/auth/introspect -Authorization: Bearer -Body: { "token": "" } - -Response: { "active": true, "userId": 42, "client": "web", "expiresAt": "...", "scopes": [...] } -``` - -Shape follows RFC 7662 so existing OAuth2 tooling can interoperate. Secured by a shared -introspection secret configured via `ogiri.introspection.secret`. - -**Impact**: Enables true microservice token validation without coupling every service to the token -store -**Effort**: Low-Medium — validation logic already exists in `OgiriTokenService`; the endpoint is -thin orchestration - ---- - -## R8: OAuth2 / OIDC Social Login Bridge - -New applications universally want "Sign in with Google/GitHub." Adopters currently implement the -OAuth2 flow themselves and call `tokenService.createNewAuthToken()` after the callback. There is no -hook point or guidance for this pattern. - -An `OgiriOAuth2LoginConfigurer` would wire into Spring Security's existing OAuth2 login -`successHandler`: - -```kotlin -@Bean -fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - http.with(OgiriOAuth2LoginConfigurer.defaults()) { cfg -> - cfg.userMapper(MyOAuth2UserMapper()) // OgiriOAuth2UserMapper SPI - } - return http.build() -} -``` - -The `OgiriOAuth2UserMapper` SPI handles user provisioning (create on first login or link to -existing account). On success, issues a standard Ogiri token instead of an OAuth2 session. - -**Impact**: Covers social login — a blocker for many new projects adopting Ogiri -**Effort**: High — Spring Security OAuth2 integration; account linking and re-authentication edge -cases are complex - ---- - -## R9: ~~Retire `ogiri-security-client/axios` Sub-Package; Add BYO Client Docs~~ ✅ Done - -`ogiri-security-client` is no longer published. The auth primitives live in -`sample/sample-react/src/lib/` and the full integration guide is at `docs/react-integration.md`. -The original design notes are preserved below for context. - -### Retire the axios sub-package (v4 breaking change) - -The `ogiri-security-client/axios` sub-entrypoint is 62 lines. The only non-obvious piece is -bridging `AxiosResponse` to a standard `Response` for `auth.extractFrom()`: - -```typescript -// The non-obvious part — the rest is mechanical -const fakeResponse = new Response(null, { headers: new Headers(headerMap) }); -auth.extractFrom(fakeResponse); -``` - -Once that pattern is documented, the adapter is copy-paste-able. The `sample-react` app's -`api/client.ts` becomes the canonical reference. Keeping it as a module means shipping a peer -dependency concern for every axios major version bump — counter to the distroless design. - -**Action for v4:** Remove `ogiri-security-client/axios`. Migrate the code into `sample-react/src/api/client.ts` -as a named, commented example. Document the fake `Response` bridge in the "Integrating with HTTP -clients" guide. - -### No additional adapters for ky / ofetch / wretch - -`ky` and `ofetch` use standard `Request`/`Response` in their hook APIs. `headerInjector()` and -`auth.extractFrom()` compose without any wrapping: - -```typescript -// ky -const api = ky.create({ - hooks: { - beforeRequest: [ - (req) => { - auth.headerInjector()(Object.fromEntries(req.headers)); - }, - ], - afterResponse: [(_, __, res) => auth.extractFrom(res.clone())], - afterResponseError: [ - (_, __, res) => { - if (res.status === 401) auth.handleAuthError(null); - }, - ], - }, -}); - -// ofetch (Nuxt / Vue) -const api = $fetch.create({ - onRequest: ({ options }) => { - options.headers = { ...options.headers, ...auth.headerInjector()({}) }; - }, - onResponse: ({ response }) => auth.extractFrom(response), -}); -``` - -No module, no sub-package — just documented patterns. New HTTP clients only get a module if their -type surface is incompatible with standard `RequestInit`/`Response` (the original axios justification). - -**Action:** Add an "Integrating with HTTP clients" docs page covering axios (copy-paste recipe), -ky, ofetch, wretch, and SSR contexts (Nuxt, Next.js server components). - -**Impact**: Completes the distroless design; removes false impression that only axios is supported; -eliminates peer dependency maintenance burden -**Effort**: Low — documentation and sample code only; axios removal is a v4 migration entry - ---- - -## R10: Spring `ApplicationEvent` Publishing from Audit Hook - -`OgiriAuditHook` is a custom SPI. Spring Boot already provides `ApplicationEventPublisher` used by -Actuator. Teams wanting event-driven reactions (welcome email on first login, Kafka event bridge) -must write a custom hook bean. - -An auto-configured `OgiriAuditEventPublisher` implementation would publish typed Spring -`ApplicationEvent`s via `ApplicationEventPublisher`: - -```kotlin -// Consumer — zero hook code needed -@EventListener -fun onLogin(event: OgiriLoginSuccessEvent) { - welcomeEmailService.sendIfFirstLogin(event.userId) -} -``` - -Published event types: `OgiriLoginSuccessEvent`, `OgiriLoginFailureEvent`, -`OgiriTokenRotatedEvent`, `OgiriTokenRevokedEvent`, `OgiriSubTokenCreatedEvent`, -`OgiriSubTokenRevokedEvent`. - -Auto-configured as the default `OgiriAuditHook` when no other `OgiriAuditHook` bean is present; -replaced automatically if the consumer provides their own. - -**Impact**: Makes event-driven security reactions zero-boilerplate; integrates with Spring ecosystem -tooling (Actuator audit log, Kafka event bridge) -**Effort**: Low — thin wrapper over `ApplicationEventPublisher`; event types map 1:1 to existing -hook methods - ---- - -## R11: `ogiri-webflux` Reactive Adapter - -`OgiriTokenAuthenticationFilter` is a `OncePerRequestFilter` — servlet-blocking only. Spring -WebFlux + R2DBC is increasingly the default for greenfield microservices. These projects cannot use -Ogiri. - -A new `ogiri-webflux` module would provide: - -- `OgiriTokenWebFilter` implementing `WebFilter` (reactive token validation via Reactor) -- `OgiriReactiveTokenRepository` SPI extending `ReactiveCrudRepository` -- `OgiriReactiveSecurityAutoConfiguration` replacing `OgiriSecurityAutoConfiguration` in reactive - contexts - -The core token logic (rotation, BCrypt comparison, sub-token management) in `OgiriTokenService` -remains unchanged — only the filter and repository interface layers need reactive variants. BCrypt -comparison is offloaded to `Schedulers.boundedElastic()` to avoid blocking the event loop. - -**Impact**: Opens Ogiri to the reactive Spring ecosystem -**Effort**: High — reactive programming model introduces non-trivial complexity; R2DBC query -translation differs from JPA/JDBC - ---- - -## R12: Android / Kotlin Multiplatform Client - -The TypeScript client targets browser + Node.js. Mobile teams building Android apps backed by an -Ogiri-secured API must implement token storage, rotation parsing, and header injection themselves. - -A `ogiri-client-kmp` Kotlin Multiplatform library (Android + JVM targets initially): - -- `OgiriAuth` — coroutine-based token state manager; API mirrors the TypeScript `OgiriAuth` - intentionally so teams using both platforms share the same mental model -- `OgiriOkHttpInterceptor` — `Interceptor` for OkHttp; handles token injection, rotation extraction, - and 401 handling -- `OgiriRetrofitCallAdapterFactory` — Retrofit adapter for projects using Retrofit over OkHttp -- Token storage uses `EncryptedSharedPreferences` on Android; in-memory for JVM/server targets - -**Impact**: Completes the client story for Android-first Kotlin teams -**Effort**: High — KMP toolchain setup, EncryptedSharedPreferences integration, Retrofit/OkHttp -adapter testing diff --git a/docs/github-pages-setup.md b/docs/github-pages-setup.md deleted file mode 100644 index 58271a5..0000000 --- a/docs/github-pages-setup.md +++ /dev/null @@ -1,295 +0,0 @@ -# GitHub Pages Setup Guide - -This guide explains how the ogiri documentation is deployed to GitHub Pages with versioning support. - -## Overview - -Documentation is automatically deployed to GitHub Pages on every push to `main` or `ori` branches. Multiple versions are supported, allowing users to view docs for different ogiri releases. - -**Live Site:** https://quantipixels.github.io/ogiri - -## Automatic Deployment - -### Trigger Events - -The `docs.yml` workflow deploys documentation when: - -1. Push to `main` or `ori` branches -2. Changes to: - - `docs/**` directory - - `mkdocs.yml` file - - `.github/workflows/docs.yml` file - -### Workflow Steps - -1. **Build** - Compiles mkdocs site -2. **Deploy** - Pushes to `gh-pages` branch - -**Duration:** ~30 seconds - -## Manual Deployment - -Deploy docs manually for specific versions: - -```bash -# Install dependencies (one-time) -pip install mkdocs-material mkdocs-mike - -# Deploy using current version from .ogiri-version -./scripts/publish-docs.sh - -# Deploy specific version -./scripts/publish-docs.sh 1.1.0 - -# Deploy as development version -./scripts/publish-docs.sh dev -``` - -The `publish-docs.sh` script: - -- Reads version from `.ogiri-version` by default -- Builds documentation with mkdocs -- Deploys using mike (multi-version support) -- Sets version as latest automatically - -## Version Management - -### How Versioning Works - -**Version Source:** `.ogiri-version` file - -**Deployment with Mike:** - -```bash -mike deploy 1.1.0 latest -``` - -This creates two deployments: - -- `1.1.0/` - Specific version archive -- `latest/` - Alias pointing to latest version - -### Accessing Versions - -**Latest (root):** - -```text -https://quantipixels.github.io/ogiri/ -``` - -**Specific version:** - -```text -https://quantipixels.github.io/ogiri/1.1.0/ -``` - -**Version switcher:** Users can select versions from dropdown (Material theme) - -## GitHub Repository Configuration - -### Enable GitHub Pages - -1. Go to repository **Settings** -2. Navigate to **Pages** -3. Select: - - **Source:** Deploy from a branch - - **Branch:** `gh-pages` - - **Folder:** `/ (root)` -4. Save - -The `docs.yml` workflow automatically creates the `gh-pages` branch. - -### Access Control - -Repository secrets (if using private GPG keys for other workflows): - -- `GITHUB_TOKEN` - Auto-provisioned by GitHub Actions -- Uses GITHUB_TOKEN for docs.yml (no secrets needed) - -## Workflow File: docs.yml - -Location: `.github/workflows/docs.yml` - -```yaml -name: Deploy Documentation -on: - push: - branches: [main, ori] - paths: - - "docs/**" - - "mkdocs.yml" - - ".github/workflows/docs.yml" -``` - -### Key Features - -- **Python 3.12** runtime -- **Dependency caching** - Faster builds -- **Conditional deployment** - Only on main/ori pushes -- **Pages artifact** - Standard GitHub Pages format -- **Concurrency control** - Prevents race conditions - -## Configuration: mkdocs.yml - -Key sections for documentation deployment: - -```yaml -site_name: Ògiri Security -site_url: https://quantipixels.github.io/ogiri -repo_url: https://github.com/quantipixels/ogiri - -extra: - version: - provider: mike # Multi-version support -``` - -## Troubleshooting - -### Docs not deploying - -1. Check **Actions** tab for `docs.yml` workflow status -2. Verify GitHub Pages is enabled (Settings → Pages) -3. Ensure `gh-pages` branch exists -4. Check workflow permissions (read/write) - -### Version not showing in dropdown - -1. Run publish script: `./scripts/publish-docs.sh` -2. Check mike installation: `pip show mkdocs-mike` -3. Verify `.ogiri-version` file contains correct version - -### Build fails locally - -```bash -# Install exact dependencies -pip install mkdocs-material==9.5.0 mkdocs-mike==2.0.0 - -# Test build -mkdocs build - -# Test serve -mkdocs serve # Visit http://localhost:8000 -``` - -### 404 on gh-pages branch - -The `gh-pages` branch is created automatically by the workflow. If missing: - -1. Delete branch: `git push origin --delete gh-pages` -2. Re-run docs workflow (push to main/ori) -3. Wait for workflow completion - -## Local Testing - -### Preview documentation locally - -```bash -# Install dependencies -pip install mkdocs-material - -# Start development server -mkdocs serve - -# Access at http://localhost:8000 -``` - -### Test version deployment locally - -```bash -# Install mike -pip install mkdocs-mike - -# Build and stage deployment -mkdocs build - -# (Don't run mike locally - requires git origin setup) -``` - -## Release Process - -### When releasing a new version - -1. Update `.ogiri-version` file: - - ```bash - echo "1.2.0" > .ogiri-version - ``` - -2. Commit and push: - - ```bash - git add .ogiri-version - git commit -m "chore: bump version to 1.2.0" - git push origin main - ``` - -3. Documentation automatically deploys with new version - -4. Or manually deploy: - ```bash - ./scripts/publish-docs.sh 1.2.0 - ``` - -### Version Aliases - -The `latest` alias always points to the most recent deployed version. Set manually: - -```bash -mike alias latest 1.2.0 -``` - -## Dependencies - -### Required (CI/CD) - -- `mkdocs-material` - Material theme -- `mkdocs-mike` - Multi-version support - -### Optional (Local) - -- `mkdocs` - Static site generator -- `pymdown-extensions` - Markdown extensions - -### Installation - -```bash -pip install mkdocs-material mkdocs-mike -``` - -## Monitoring - -### View deployment history - -1. Repository → **Actions** → **Deploy Documentation** -2. Check workflow runs -3. Click run to see detailed logs - -### Monitor site health - -- Visit: https://quantipixels.github.io/ogiri -- Check latest version loads -- Verify dropdown shows all versions -- Test navigation between pages - -## FAQ - -**Q: Can I deploy from a different branch?** -A: Update `docs.yml` to include your branch in `on.push.branches` - -**Q: How do I delete an old version?** -A: Use mike: `mike delete 1.0.0 && mike deploy --push` - -**Q: Can I deploy docs on release tags?** -A: Yes, modify `docs.yml` to trigger on tags: `on: [push: {tags: ['v*']}]` - -**Q: How long are old versions kept?** -A: As long as `gh-pages` branch exists (no automatic cleanup) - -**Q: Can I customize the version selector?** -A: Yes, edit `mkdocs.yml` `extra.version` section - -## Resources - -- [Material for MkDocs Docs](https://squidfunk.github.io/mkdocs-material/) -- [Mike Documentation](https://github.com/jimporter/mike) -- [GitHub Pages Docs](https://docs.github.com/en/pages) diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 7c0ecdd..0000000 --- a/docs/index.md +++ /dev/null @@ -1,114 +0,0 @@ -# Ògiri Security - -Ògiri is a Spring Boot security library for token-based authentication with pluggable sub-token support. It handles token issuance, validation, rotation, and cleanup without locking you into a specific database or persistence layer. - -## Why Ògiri? - -- **Database Freedom** — Use JPA, MongoDB, Redis, or any custom persistence -- **Zero Configuration** — Works out of the box with sensible defaults -- **Flexible Tokens** — Support for sub-tokens (device, chat, API) alongside main tokens -- **Production Ready** — BCrypt hashing, automatic rotation, batch request detection -- **Optional Caching** — Drop in `ogiri-caffeine` or `ogiri-redis` to eliminate per-request DB reads; explicit opt-in, zero impact if absent - -## Getting Started - -Add the dependency and implement two interfaces: - -=== "Kotlin" - - ```kotlin - // 1. Add dependency (choose one) - implementation("com.quantipixels.ogiri:ogiri-jpa:{{ config.extra.ogiri_version }}") // For JPA (recommended) - // OR - implementation("com.quantipixels.ogiri:ogiri-core:{{ config.extra.ogiri_version }}") // For custom persistence - - // 2. Connect to your user system - @Component - class MyUserDirectory(private val userService: UserService) : OgiriUserDirectory { - override fun findById(id: Long) = userService.getById(id) - override fun findByUsername(username: String) = userService.getByUsername(username) - override fun findByEmail(email: String) = userService.getByEmail(email) - override fun loadUserByUsername(username: String) = userService.getByUsername(username) ?: throw UsernameNotFoundException("User not found: $username") - override fun recordSuccessfulLogin(userId: Long) { userService.recordLogin(userId) } - } - - // 3. Declare public routes - @Component - class MyRouteRegistry : OgiriRouteRegistry { - override fun routes() = listOf(OgiriRoute.post("/api/auth/**")) - } - ``` - -=== "Java" - - ```java - // 1. Add dependency (choose one) - // implementation("com.quantipixels.ogiri:ogiri-jpa:{{ config.extra.ogiri_version }}") // For JPA (recommended) - // OR - // implementation("com.quantipixels.ogiri:ogiri-core:{{ config.extra.ogiri_version }}") // For custom persistence - - // 2. Connect to your user system - @Component - public class MyUserDirectory implements OgiriUserDirectory { - private final UserService userService; - - public MyUserDirectory(UserService userService) { - this.userService = userService; - } - - @Override public OgiriUser findById(Long id) { return userService.getById(id); } - @Override public OgiriUser findByUsername(String username) { return userService.getByUsername(username); } - @Override public OgiriUser findByEmail(String email) { return userService.getByEmail(email); } - @Override public OgiriUser loadUserByUsername(String username) { - OgiriUser user = userService.getByUsername(username); - if (user == null) throw new UsernameNotFoundException("User not found: " + username); - return user; - } - @Override public void recordSuccessfulLogin(Long userId) { userService.recordLogin(userId); } - } - - // 3. Declare public routes - @Component - public class MyRouteRegistry implements OgiriRouteRegistry { - @Override - public List routes() { - return List.of(OgiriRoute.post("/api/auth/**")); - } - } - ``` - -That's it. Ògiri auto-configures the security filter chain. - -**[Full Quickstart Guide](quickstart.md)** - Complete setup in 5 minutes with Kotlin and Java examples. - -## Documentation - -### Getting Started - -- [Quickstart](quickstart.md) - Get running in 5 minutes - -### Integration & Configuration - -- [Configuration](configuration.md) - All configuration properties -- [Database Integration](database.md) - JPA, MongoDB, Redis, custom adapters -- [Sub-tokens](sub-tokens.md) - Device, chat, API tokens - -### Reference - -- [Authentication Flow](authentication.md) - Request lifecycle and headers -- [Sample Applications](https://github.com/quantipixels/ogiri/tree/main/sample) - Java and Kotlin examples - -### Contributing - -- [Development Guide](development.md) - Build, test, contribute -- [Changelog](changelog.md) - Release history - -## Requirements - -- Java 17+ -- Spring Boot 3.5+ -- Your choice of database - -## License - -Apache License 2.0 diff --git a/docs/interface-first-design.md b/docs/interface-first-design.md deleted file mode 100644 index 8d1d721..0000000 --- a/docs/interface-first-design.md +++ /dev/null @@ -1,393 +0,0 @@ -# Interface-First Design - -The Ogiri security library uses an **interface-first design pattern** to provide maximum flexibility while maintaining a clean, predictable API. - -## Core Principle - -> "Program to interfaces, not implementations" - -This means: - -1. **Define what the library needs** via interfaces (`OgiriToken`, `OgiriTokenRepository`, `OgiriTokenService`) -2. **Don't prescribe how users implement it** - no forced inheritance -3. **Provide convenient base implementations** for simple cases (`OgiriBaseToken`) -4. **Let power users compose their own solutions** without restrictions - -## Core Interfaces - -### OgiriToken - -The primary contract for all token implementations. - -```kotlin -interface OgiriToken { - val id: Long - val userId: Long - val client: String - var token: String - val tokenType: String - var expiryAt: Instant - val createdAt: Instant - val updatedAt: Instant - var tokenUpdatedAt: Instant - var tokenSubtype: String? - var lastToken: String? - var previousToken: String? - var lastUsedAt: Instant? - var plainToken: String? - - fun isExpired(now: Instant = Instant.now()): Boolean = expiryAt.isBefore(now) -} -``` - -**No implementation required** - you just declare which interface you implement. - -### OgiriTokenRepository\ - -Database-agnostic persistence contract. - -```kotlin -interface OgiriTokenRepository { - fun save(token: T): T - fun findById(id: Long): Optional - fun deleteById(id: Long) - fun findByUserIdOrderByUpdatedAtDesc(userId: Long): List - fun findByUserIdAndClient(userId: Long, client: String): Optional - // ... and more -} -``` - -Implement with: - -- Spring Data JPA -- JDBC Template -- MongoDB -- Redis -- Any persistence layer you prefer - -### OgiriTokenService\ - -Core token orchestration service. - -```kotlin -open class OgiriTokenService( - private val repository: OgiriTokenRepository, - private val passwordEncoder: PasswordEncoder, - private val userDirectory: OgiriUserDirectory, - private val identifierPolicy: IdentifierPolicy, - private val subTokenRegistry: OgiriSubTokenRegistry, - protected val properties: OgiriConfigurationProperties, -) -``` - -Optional collaborators (`OgiriAuditHook`, `OgiriRateLimitHook`, `OgiriTokenLookupCache`) are wired post-construction via `setAuditHook()`, `setRateLimitHook()`, and `setLookupCache()`. Auto-configuration calls these setters automatically when the corresponding beans are present. - -Works with any `OgiriToken` implementation. No need to extend - just provide a token that implements the interface. - -## Implementation Patterns - -### Pattern 1: Direct Interface Implementation (Maximum Flexibility) - -For complete control over your token structure: - -```kotlin -@Entity -@Table(name = "user_tokens") -data class MyToken( - @Id @GeneratedValue override val id: Long = 0, - @Column(name = "user_id") override val userId: Long, - @Column(name = "client") override val client: String, - @Column(name = "token_hash") override var token: String, - @Column(name = "token_type") override val tokenType: String, - @Column(name = "expiry_at") override var expiryAt: Instant, - @CreationTimestamp override val createdAt: Instant = Instant.now(), - @UpdateTimestamp override val updatedAt: Instant = Instant.now(), - @Column(name = "token_updated_at") override var tokenUpdatedAt: Instant, - @Column(name = "token_subtype") override var tokenSubtype: String? = null, - @Column(name = "last_token_hash") override var lastToken: String? = null, - @Column(name = "previous_token_hash") override var previousToken: String? = null, - @Column(name = "last_used_at") override var lastUsedAt: Instant? = null, - - // Your custom fields - @Column(name = "device_id") val deviceId: String? = null, - @Column(name = "ip_address") val ipAddress: String? = null, -) : OgiriToken { - @Transient - override var plainToken: String? = null -} -``` - -**Advantages:** - -- Complete flexibility over token structure -- Can add custom fields freely -- Can inherit from your own base class (via mixin if needed in Kotlin, multiple inheritance patterns in Java) -- Works exactly like before - -### Pattern 2: Extend OgiriBaseToken (Convenience) - -For straightforward implementations: - -```kotlin -@Entity -@Table(name = "user_tokens") -data class MyToken( - @Id @GeneratedValue override val id: Long = 0, - @Column(name = "user_id") override val userId: Long, - @Column(name = "client") override val client: String, - @Column(name = "token_hash") override var token: String, - @Column(name = "token_type") override val tokenType: String, - @Column(name = "expiry_at") override var expiryAt: Instant, - @CreationTimestamp override val createdAt: Instant = Instant.now(), - @UpdateTimestamp override val updatedAt: Instant = Instant.now(), - @Column(name = "token_updated_at") override var tokenUpdatedAt: Instant, -) : OgiriBaseToken() -``` - -**Advantages:** - -- Sensible defaults for optional properties -- Less boilerplate code -- Still provides all required properties -- Clear inheritance hierarchy - -## Repository Implementation - -### Using Spring Data JPA - -Most `OgiriTokenRepository` methods match Spring Data's derived query naming conventions — -they are generated automatically. Only methods that need custom SQL require `@Query`: - -```kotlin -@Repository -interface MyTokenRepository : JpaRepository, OgiriTokenRepository { - - // Spring Data auto-generates from the method name — no override needed: - // findByUserIdOrderByUpdatedAtDesc(userId) - // findByUserIdAndClient(userId, client) → Optional - // findByUserIdAndClientIn(userId, clients) → List - // findByExpiryAtBefore(cutoff) → List - - // Use @Query for methods that need custom SQL: - @Query("SELECT COUNT(t) FROM MyToken t WHERE t.userId = :userId") - override fun countByUserId(userId: Long): Long - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.userId = ?1 AND t.client = ?2") - override fun deleteByUserIdAndClient(userId: Long, client: String) - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.expiryAt < ?1") - override fun deleteByExpiryAtBefore(cutoff: Instant): Int -} -``` - -See [Database Integration](database.md#quick-start-with-jpa-recommended) for the full setup. - -### Using Plain JDBC - -For lightweight SQL without Hibernate, use `ogiri-jdbc` which provides `OgiriJdbcTokenRepository` — -an abstract class that auto-implements all 15 repository methods. You only implement `tableName()` -and `rowMapper()`. See [Database Integration](database.md#quick-start-with-jdbc) for the full setup. - -For a fully custom JDBC implementation: - -```kotlin -@Repository -class MyTokenRepository(private val jdbcClient: JdbcClient) : OgiriTokenRepository { - override fun save(token: S): S { - // INSERT or UPDATE logic - return token - } - - override fun findById(id: Long): Optional { - // SELECT logic - return Optional.empty() - } - - // ... implement remaining methods -} -``` - -## Service Implementation - -### Custom Token Factory - -Extend `OgiriTokenService` and provide a custom token factory: - -```kotlin -@Service -class MyTokenService( - repository: MyTokenRepository, // concrete subtype — Spring resolves via ResolvableType - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, -) : OgiriTokenService( - repository, passwordEncoder, userDirectory, - identifierPolicy, subTokenRegistry, properties, -) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): MyToken = MyToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.label, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - plainToken = plainTokenValue - ) -} -``` - -## Design Benefits - -### 1. Flexibility - -- No inheritance constraints -- Your token can extend your own base class -- Add custom fields without modifying library code -- Use any persistence layer - -### 2. Testability - -- Implement `OgiriToken` with a simple data class for tests -- No need for database setup in unit tests -- Mock `OgiriTokenRepository` easily - -### 3. Composition - -- Mix and match implementations -- Use different token types for different purposes -- Easy to extend without modifying existing code - -### 4. Clarity - -- Interfaces document the contract explicitly -- No implicit dependencies on base classes -- Easy to understand what's required - -## Complete Example - -### Token Entity (Direct Implementation) - -```kotlin -@Entity -@Table( - name = "user_tokens", - indexes = [ - Index(name = "idx_tokens_user_id", columnList = "user_id"), - Index(name = "idx_tokens_expiry", columnList = "expiry_at") - ], - uniqueConstraints = [ - UniqueConstraint(name = "uk_tokens_user_client", columnNames = ["user_id", "client"]) - ] -) -data class UserToken( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long = 0, - - @Column(name = "user_id", nullable = false) - override val userId: Long, - - @Column(name = "client", nullable = false) - override val client: String, - - @Column(name = "token_hash", nullable = false) - override var token: String, - - @Column(name = "token_type", nullable = false) - override val tokenType: String = "app", - - @Column(name = "expiry_at", nullable = false) - override var expiryAt: Instant, - - @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) - override val createdAt: Instant = Instant.now(), - - @UpdateTimestamp @Column(name = "updated_at", nullable = false) - override val updatedAt: Instant = Instant.now(), - - @Column(name = "token_updated_at", nullable = false) - override var tokenUpdatedAt: Instant = Instant.now(), - - @Column(name = "token_subtype") - override var tokenSubtype: String? = null, - - @Column(name = "last_token_hash") - override var lastToken: String? = null, - - @Column(name = "previous_token_hash") - override var previousToken: String? = null, - - @Column(name = "last_used_at") - override var lastUsedAt: Instant? = null, -) : OgiriToken { - @Transient - override var plainToken: String? = null -} -``` - -### Repository - -```kotlin -@Repository -interface UserTokenRepository : JpaRepository, OgiriTokenRepository { - // ... implement required methods -} -``` - -### Service - -```kotlin -@Service -class UserTokenService( - repository: UserTokenRepository, // concrete subtype — Spring resolves via ResolvableType - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, -) : OgiriTokenService( - repository, passwordEncoder, userDirectory, - identifierPolicy, subTokenRegistry, properties, -) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): UserToken = UserToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.label, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - plainToken = plainTokenValue - ) -} -``` - -## Summary - -The interface-first design gives you: - -✅ **Maximum flexibility** - Implement the interfaces your way -✅ **No forced inheritance** - Use composition if you prefer -✅ **Clear contracts** - Interfaces document exactly what's needed -✅ **Easy testing** - Mock implementations are straightforward -✅ **Backward compatible** - Existing `OgiriBaseToken` still works - -For simple cases, extend `OgiriBaseToken`. For complex needs, implement `OgiriToken` directly. Both approaches work perfectly with the rest of the library. diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index ec3f03d..0000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,323 +0,0 @@ -# Quickstart - -Get ogiri integrated into your Spring Boot application in 5 minutes. - -## 1. Add Dependency - -=== "JPA (Recommended)" - - **Gradle (Kotlin DSL):** - ```kotlin - implementation("com.quantipixels.ogiri:ogiri-jpa:{{ config.extra.ogiri_version }}") - ``` - - **Gradle (Groovy):** - ```groovy - implementation 'com.quantipixels.ogiri:ogiri-jpa:{{ config.extra.ogiri_version }}' - ``` - - **Maven:** - ```xml - - com.quantipixels.ogiri - ogiri-jpa - {{ config.extra.ogiri_version }} - - ``` - - Includes `ogiri-core` and `spring-boot-starter-data-jpa` transitively. **Reduces boilerplate by ~70%.** - -=== "JDBC" - - **Gradle (Kotlin DSL):** - ```kotlin - implementation("com.quantipixels.ogiri:ogiri-jdbc:{{ config.extra.ogiri_version }}") - ``` - - **Gradle (Groovy):** - ```groovy - implementation 'com.quantipixels.ogiri:ogiri-jdbc:{{ config.extra.ogiri_version }}' - ``` - - **Maven:** - ```xml - - com.quantipixels.ogiri - ogiri-jdbc - {{ config.extra.ogiri_version }} - - ``` - - Includes `ogiri-core` and `spring-boot-starter-jdbc` transitively. No Hibernate, no `@Entity`. See [JDBC setup](database.md#quick-start-with-jdbc). - -=== "Core Only" - - **Gradle (Kotlin DSL):** - ```kotlin - implementation("com.quantipixels.ogiri:ogiri-core:{{ config.extra.ogiri_version }}") - ``` - - **Gradle (Groovy):** - ```groovy - implementation 'com.quantipixels.ogiri:ogiri-core:{{ config.extra.ogiri_version }}' - ``` - - **Maven:** - ```xml - - com.quantipixels.ogiri - ogiri-core - {{ config.extra.ogiri_version }} - - ``` - - For MongoDB, Redis, or custom persistence implementations. - -## 2. Implement Required Interfaces - -Ògiri requires two interfaces to connect with your application's user system and routing. - -### OgiriUserDirectory - -Connects Ògiri to your user database: - -=== "Kotlin" - - ```kotlin - @Component - class MyUserDirectory(private val userService: UserService) : OgiriUserDirectory { - - override fun findById(id: Long): OgiriUser? = userService.getById(id) - - override fun findByUsername(username: String): OgiriUser? = userService.getByUsername(username) - - override fun findByEmail(email: String): OgiriUser? = userService.getByEmail(email) - - override fun loadUserByUsername(username: String): OgiriUser = - userService.getByUsername(username) ?: throw UsernameNotFoundException(username) - - override fun recordSuccessfulLogin(userId: Long) { - userService.recordLogin(userId) - } - } - ``` - -=== "Java" - - ```java - @Component - public class MyUserDirectory implements OgiriUserDirectory { - private final UserService userService; - - public MyUserDirectory(UserService userService) { - this.userService = userService; - } - - @Override - public OgiriUser findById(Long id) { - return userService.getById(id); - } - - @Override - public OgiriUser findByUsername(String username) { - return userService.getByUsername(username); - } - - @Override - public OgiriUser findByEmail(String email) { - return userService.getByEmail(email); - } - - @Override - public OgiriUser loadUserByUsername(String username) { - OgiriUser user = userService.getByUsername(username); - if (user == null) throw new UsernameNotFoundException(username); - return user; - } - - @Override - public void recordSuccessfulLogin(Long userId) { - userService.recordLogin(userId); - } - } - ``` - -### RouteRegistry - -Declares which routes bypass authentication: - -=== "Kotlin" - - ```kotlin - @Component - class MyRouteRegistry : OgiriRouteRegistry { - override fun routes() = listOf( - OgiriRoute.post("/api/auth/login"), - OgiriRoute.post("/api/auth/register"), - OgiriRoute.get("/api/health"), - ) - } - ``` - -=== "Java" - - ```java - @Component - public class MyRouteRegistry implements OgiriRouteRegistry { - @Override - public List routes() { - return List.of( - OgiriRoute.post("/api/auth/login"), - OgiriRoute.post("/api/auth/register"), - OgiriRoute.get("/api/health") - ); - } - } - ``` - -### Token Persistence - -If using `ogiri-jpa`, create a simple token entity extending `OgiriBaseTokenEntity`: - -=== "Kotlin" - - ```kotlin - @Entity - @Table(name = "user_tokens") - class MyToken : OgiriBaseTokenEntity() - ``` - -=== "Java" - - ```java - @Entity - @Table(name = "user_tokens") - public class MyToken extends OgiriBaseTokenEntity {} - ``` - -Then create your repository adapter. See [Database Integration](database.md) for the complete setup with JPA, MongoDB, Redis, and custom implementations. - -## 3. Issue Tokens on Login - -=== "Kotlin" - - ```kotlin - @RestController - class AuthController(private val tokenService: OgiriTokenService) { - - @PostMapping("/api/auth/login") - fun login(@RequestBody request: LoginRequest, response: HttpServletResponse): ResponseEntity<*> { - val user = authenticate(request.username, request.password) - val authHeader = tokenService.createNewAuthToken(user.id, "web") - response.appendAuthHeaders(authHeader) - return ResponseEntity.ok(mapOf("message" to "Login successful")) - } - } - ``` - -=== "Java" - - ```java - @RestController - public class AuthController { - private final OgiriTokenService tokenService; - - public AuthController(OgiriTokenService tokenService) { - this.tokenService = tokenService; - } - - @PostMapping("/api/auth/login") - public ResponseEntity login(@RequestBody LoginRequest request, HttpServletResponse response) { - User user = authenticate(request.getUsername(), request.getPassword()); - AuthHeader authHeader = tokenService.createNewAuthToken(user.getId(), "web"); - AuthHeaderKt.appendAuthHeaders(response, authHeader); - return ResponseEntity.ok(Map.of("message", "Login successful")); - } - } - ``` - -## Done! - -Ògiri auto-configures the security filter chain. Authenticated requests will have their tokens validated and rotated automatically. - -**Response headers after login:** - -```text -access-token: -client: web -uid: 123 -expiry: 2025-12-25T00:00:00Z -``` - -**Client sends on subsequent requests:** - -```text -access-token: -client: web -uid: 123 -expiry: 2025-12-25T00:00:00Z -``` - -## Route Authorization - -Ògiri handles **authentication** — verifying who the caller is — but it does not configure **authorization** — which routes they may access. - -The `ogiriSecurityFilterChain` bean deliberately omits `authorizeHttpRequests()`. Without it, Spring Security's default behaviour leaves all routes open to unauthenticated requests. You must define your own `SecurityFilterChain` bean to close them: - -=== "Kotlin" - - ```kotlin - @Bean - fun appSecurityFilterChain(http: HttpSecurity): SecurityFilterChain = - http - .authorizeHttpRequests { auth -> - auth.requestMatchers("/api/auth/**").permitAll() - auth.anyRequest().authenticated() - } - .build() - ``` - -=== "Java" - - ```java - @Bean - public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception { - return http - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**").permitAll() - .anyRequest().authenticated() - ) - .build(); - } - ``` - -Spring Security applies multiple `SecurityFilterChain` beans in order; this bean adds your authorization rules without replacing Ògiri's authentication filter. - -## Optional: SPI Hooks - -Ogiri provides optional SPI hooks and cache modules you can opt into as Spring beans or extra dependencies. - -**Hooks** (implement as a `@Component`, no extra dependency needed): - -- **`OgiriAuditHook`** — Callbacks on login success/failure, token rotation, and revocation (integrate with your SIEM) -- **`OgiriRateLimitHook`** — Enforce rate limits before login and token creation (e.g., Bucket4j, Redis) - -Both default to no-ops when no bean is present. When a bean is registered, the ogiri auto-configuration wires it into your `OgiriTokenService` via setter injection (`setAuditHook` / `setRateLimitHook`) automatically. - -**Token Lookup Cache** (eliminates per-request DB reads for the same user/client): - -- **`ogiri-caffeine`** — In-process Caffeine cache. Add the dependency and set `ogiri.lookup.type: caffeine`. Best for single-instance deployments. -- **`ogiri-redis`** — Shared Redis cache. Add `ogiri-redis` + `spring-boot-starter-data-redis` and set `ogiri.lookup.type: redis`. Required for multi-instance deployments where revocations must propagate across nodes. - -See [Token Lookup Cache](configuration.md#token-lookup-cache) for full setup instructions. - -## Next Steps - -| Topic | Description | -| ----------------------------------------------------------------------------- | ------------------------------------------------ | -| [Configuration](configuration.md) | Token rotation, cleanup schedules, batch windows | -| [Token Lookup Cache](configuration.md#token-lookup-cache) | Caffeine and Redis cache modules, opt-in setup | -| [Database Integration](database.md) | JPA, MongoDB, Redis, custom implementations | -| [Sub-tokens](sub-tokens.md) | Device tokens, chat tokens, API tokens | -| [Authentication Flow](authentication.md) | Request lifecycle, rotation policies, headers | -| [Sample Applications](https://github.com/quantipixels/ogiri/tree/main/sample) | Complete Java and Kotlin examples | diff --git a/docs/react-integration.md b/docs/react-integration.md deleted file mode 100644 index a3ae0c7..0000000 --- a/docs/react-integration.md +++ /dev/null @@ -1,539 +0,0 @@ -# React Integration - -This guide shows how to integrate Ogiri token authentication into a React app. - -> **No npm package required.** Copy the two files from -> [`sample/sample-react/src/lib/`](../sample/sample-react/src/lib/) directly into your project. -> They have zero runtime dependencies. - ---- - -## 1. Copy the auth primitives - -Copy these two files into your project (e.g. `src/lib/`): - -| File | Purpose | -| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| [`auth.ts`](../sample/sample-react/src/lib/auth.ts) | Token types, storage, `OgiriAuth` state manager, pure `injectAuth`/`extractTokens` functions | -| [`axios-ogiri.ts`](../sample/sample-react/src/lib/axios-ogiri.ts) | Axios interceptors — only needed if you use axios | - -`auth.ts` has no external dependencies. `axios-ogiri.ts` depends only on axios (which you already have). - ---- - -## 2. Create the auth instance - -```typescript -// src/api/client.ts -import { OgiriAuth, LocalStorageTokenStorage } from "../lib/auth"; -import { createAxiosInterceptors } from "../lib/axios-ogiri"; -import axios from "axios"; - -export const auth = new OgiriAuth({ - authMethod: "headers", // or "bearer" — match your server config - storage: new LocalStorageTokenStorage(), // persists across page reloads -}); - -export const api = axios.create({ baseURL: "https://api.example.com" }); - -const { request, response } = createAxiosInterceptors(auth); -api.interceptors.request.use(request); -api.interceptors.response.use(response.onFulfilled, response.onRejected); -``` - -The interceptors handle three things automatically: - -- **Request**: injects `access-token`, `client`, `uid`, `expiry`, `token-type` headers -- **Response**: extracts rotated token headers and updates stored tokens -- **401**: clears stored tokens and fires `onAuthError` if configured - ---- - -## 3. Expose auth state to React with `useSyncExternalStore` - -`OgiriAuth` is a plain event emitter (`subscribe`/`notify`). Hook it into React's external store -primitive so components re-render when tokens change: - -```tsx -// src/auth/AuthProvider.tsx -import { - createContext, - useCallback, - useMemo, - useSyncExternalStore, - type ReactNode, -} from "react"; -import { auth } from "../api/client"; -import type { OgiriTokens } from "../lib/auth"; - -interface AuthContextValue { - isAuthenticated: boolean; - tokens: OgiriTokens | null; - login: (tokens: OgiriTokens) => void; - logout: () => void; -} - -export const AuthContext = createContext(null); - -export function AuthProvider({ children }: { children: ReactNode }) { - const tokens = useSyncExternalStore( - (cb) => auth.subscribe(cb), // subscribe - () => auth.getTokens() // getSnapshot - ); - - const login = useCallback((t: OgiriTokens) => auth.setTokens(t), []); - const logout = useCallback(() => auth.clearTokens(), []); - - const value = useMemo( - () => ({ isAuthenticated: tokens !== null, tokens, login, logout }), - [tokens, login, logout] - ); - - return {children}; -} -``` - -`useSyncExternalStore` is the React 18+ standard for subscribing to non-React state. It correctly -handles concurrent rendering without tearing. - ---- - -## 4. Consume auth state in components - -```tsx -// src/auth/useAuth.ts -import { useContext } from "react"; -import { AuthContext } from "./AuthProvider"; - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used within AuthProvider"); - return ctx; -} -``` - -```tsx -// Protected route -function ProtectedRoute({ children }: { children: ReactNode }) { - const { isAuthenticated } = useAuth(); - if (!isAuthenticated) return ; - return children; -} -``` - ---- - -## 5. Handle login - -After a successful login POST, the server returns tokens in the response body **and** sets them in -response headers (for rotation). Store whichever you receive: - -```typescript -// src/api/queries.ts -export function useLogin() { - return useMutation({ - mutationFn: (creds: { username: string; password: string }) => - api.post("/api/auth/login", creds), - onSuccess: ({ data }) => { - // Map server response fields to OgiriTokens - auth.setTokens({ - accessToken: data["access-token"], - client: data.client, - uid: data.uid, - expiry: data.expiry, - tokenType: data["token-type"], - }); - }, - }); -} -``` - -After login, every subsequent request automatically carries the current tokens. When the server -rotates tokens (sends new headers), the response interceptor updates storage and notifies -`AuthProvider` — components re-render with the new tokens without any extra code. - ---- - -## 6. Token rotation display - -The sample app demonstrates live token rotation using a `prevToken` ref: - -```tsx -// src/components/TokenDisplay.tsx — from sample-react -export function TokenDisplay({ tokens }: { tokens: OgiriTokens | null }) { - const prevRef = useRef(null); - const [highlight, setHighlight] = useState(false); - - useEffect(() => { - const current = tokens?.accessToken ?? null; - if (prevRef.current !== null && prevRef.current !== current) { - setHighlight(true); - const t = setTimeout(() => setHighlight(false), 1000); - return () => clearTimeout(t); - } - prevRef.current = current; - }, [tokens?.accessToken]); - - if (!tokens) return

No tokens

; - return ( -
- {tokens.accessToken.substring(0, 8)}… -
- ); -} -``` - ---- - -## 7. HTTP clients - -`OgiriAuth` exposes three primitives that map onto any HTTP client: - -| Primitive | What it does | -| ---------------------------- | ------------------------------------------------------------------ | -| `auth.injectInto(config)` | Returns a new `RequestInit` with auth headers merged in | -| `auth.extractFrom(response)` | Reads rotation headers from a `Response` and updates stored tokens | -| `auth.headerInjector()` | Returns `(headers) => headers` — merges auth into a plain object | - -=== "axios" - - Full source: [`sample/sample-react/src/api/client.ts`](../sample/sample-react/src/api/client.ts) - and [`src/lib/axios-ogiri.ts`](../sample/sample-react/src/lib/axios-ogiri.ts). - - ```typescript - import { OgiriAuth, LocalStorageTokenStorage } from "./lib/auth"; - import { createAxiosInterceptors } from "./lib/axios-ogiri"; - import axios from "axios"; - - export const auth = new OgiriAuth({ - authMethod: "headers", - storage: new LocalStorageTokenStorage(), - }); - - export const api = axios.create({ baseURL: "https://api.example.com" }); - - const { request, response } = createAxiosInterceptors(auth); - api.interceptors.request.use(request); - api.interceptors.response.use(response.onFulfilled, response.onRejected); - ``` - - The `createAxiosInterceptors` function in `axios-ogiri.ts` bridges axios's non-standard - `InternalAxiosRequestConfig`/`AxiosResponse` types to the `RequestInit`/`Response` primitives - that `auth.injectInto()` and `auth.extractFrom()` expect. - -=== "fetch" - - No adapter file needed — `auth.injectInto()` and `auth.extractFrom()` accept the standard - Fetch API types directly. - - ```typescript - // src/api/client.ts - import { OgiriAuth, LocalStorageTokenStorage, OgiriAuthError } from "./lib/auth"; - - export const auth = new OgiriAuth({ - authMethod: "headers", - storage: new LocalStorageTokenStorage(), - }); - - export async function ogiriFetch(path: string, options: RequestInit = {}): Promise { - const response = await fetch( - `https://api.example.com${path}`, - auth.injectInto(options), - ); - - if (response.status === 401) { - const body = await response.json().catch(() => null); - throw auth.handleAuthError(body); - } - - if (!response.ok) { - throw new Error( - `${options.method ?? "GET"} ${path} failed: ${response.status} ${response.statusText}`, - ); - } - - auth.extractFrom(response.clone()); // clone before consuming body - return response.json() as Promise; - } - ``` - - Login — tokens arrive in the response body on the first request, then rotate via headers: - - ```typescript - const data = await ogiriFetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), - }); - // ogiriFetch calls auth.extractFrom() — tokens stored automatically - ``` - -=== "ky" - - ky uses standard `Request`/`Response` in its hook API, so no adapter file is needed. - Use `afterResponse` (not `afterResponseError` — that hook does not exist in ky) to handle - both rotation and 401s: - - ```typescript - import ky from "ky"; - import { OgiriAuth, LocalStorageTokenStorage } from "./lib/auth"; - - export const auth = new OgiriAuth({ - authMethod: "headers", - storage: new LocalStorageTokenStorage(), - }); - - export const api = ky.create({ - prefixUrl: "https://api.example.com", - hooks: { - beforeRequest: [ - (request) => { - for (const [key, value] of Object.entries(auth.headerInjector()({}))) { - request.headers.set(key, value); - } - }, - ], - afterResponse: [ - (_, __, response) => { - if (response.status === 401) { - auth.handleAuthError(null); - } else { - auth.extractFrom(response.clone()); - } - return response; - }, - ], - }, - }); - ``` - -=== "ofetch" - - `ofetch`'s `FetchResponse` extends the standard `Response`, so `auth.extractFrom()` accepts - it directly. `onResponseError` handles 401s separately from successful responses: - - ```typescript - import { $fetch, type FetchOptions } from "ofetch"; - import { OgiriAuth, LocalStorageTokenStorage } from "./lib/auth"; - - export const auth = new OgiriAuth({ - authMethod: "headers", - storage: new LocalStorageTokenStorage(), - }); - - const baseOptions: FetchOptions = { - baseURL: "https://api.example.com", - onRequest: ({ options }) => { - options.headers = { ...(options.headers as object), ...auth.headerInjector()({}) }; - }, - onResponse: ({ response }) => { - auth.extractFrom(response); - }, - onResponseError: ({ response }) => { - if (response.status === 401) auth.handleAuthError(null); - }, - }; - - export const api = $fetch.create(baseOptions); - ``` - ---- - -## 8. React state management - -`OgiriAuth` is a plain observable — it has a `subscribe(listener)` method that fires whenever -tokens change (login, logout, or rotation from an HTTP response). Any state manager can bridge it -with a one-line subscription. - -=== "useSyncExternalStore" - - The built-in React 18 primitive. No extra dependencies. - Full source: [`sample/sample-react/src/auth/AuthProvider.tsx`](../sample/sample-react/src/auth/AuthProvider.tsx) - - ```tsx - // src/auth/AuthProvider.tsx - import { createContext, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; - import { auth } from "../api/client"; - import type { OgiriTokens } from "../lib/auth"; - - interface AuthContextValue { - isAuthenticated: boolean; - tokens: OgiriTokens | null; - login: (tokens: OgiriTokens) => void; - logout: () => void; - } - - export const AuthContext = createContext(null); - - export function AuthProvider({ children }: { children: ReactNode }) { - const tokens = useSyncExternalStore( - (cb) => auth.subscribe(cb), // subscribe — returns unsubscribe fn - () => auth.getTokens(), // getSnapshot - ); - - const login = useCallback((t: OgiriTokens) => auth.setTokens(t), []); - const logout = useCallback(() => auth.clearTokens(), []); - const value = useMemo( - () => ({ isAuthenticated: tokens !== null, tokens, login, logout }), - [tokens, login, logout], - ); - - return {children}; - } - ``` - - ```tsx - // src/auth/useAuth.ts — full source in sample-react - import { useContext } from "react"; - import { AuthContext } from "./AuthProvider"; - - export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used within AuthProvider"); - return ctx; - } - ``` - - `useSyncExternalStore` guarantees tear-free reads under React 18 concurrent rendering. - `auth.subscribe` returns an unsubscribe function, which is exactly what React expects. - -=== "Zustand" - - Bridge `OgiriAuth`'s `subscribe` into a Zustand store. Keep `OgiriAuth` as the source of - truth — the store is a reactive read layer, not a second state owner. - - ```typescript - // src/store/auth-store.ts - import { create } from "zustand"; - import { auth } from "../api/client"; - import type { OgiriTokens } from "../lib/auth"; - - interface AuthStore { - tokens: OgiriTokens | null; - isAuthenticated: boolean; - login: (tokens: OgiriTokens) => void; - logout: () => void; - } - - export const useAuthStore = create(() => ({ - tokens: auth.getTokens(), - isAuthenticated: auth.isAuthenticated(), - login: (tokens) => auth.setTokens(tokens), - logout: () => auth.clearTokens(), - })); - - // Keep the store in sync with OgiriAuth (rotation from interceptors, external logouts, etc.) - auth.subscribe(() => - useAuthStore.setState({ - tokens: auth.getTokens(), - isAuthenticated: auth.isAuthenticated(), - }), - ); - ``` - - In components: - - ```tsx - import { useAuthStore } from "../store/auth-store"; - - function Navbar() { - const { isAuthenticated, logout } = useAuthStore(); - return isAuthenticated ? : null; - } - - // Select only what you need to avoid unnecessary re-renders - function TokenBadge() { - const tokens = useAuthStore((s) => s.tokens); - return tokens ? {tokens.accessToken.substring(0, 8)}… : null; - } - ``` - - Login — call `auth.setTokens()` (or the store's `login` action) after receiving tokens from - the login endpoint. The `subscribe` callback updates the store automatically: - - ```typescript - const { login } = useAuthStore.getState(); - login({ accessToken: data["access-token"], client: data.client, ... }); - // → auth.setTokens() fires → subscribe callback → store re-renders - ``` - -=== "TanStack Store" - - `@tanstack/store` + `@tanstack/react-store`. Same pattern as Zustand: `OgiriAuth` owns the - data, the Store is the reactive bridge. - - ```typescript - // src/store/auth-store.ts - import { Store } from "@tanstack/store"; - import { useStore } from "@tanstack/react-store"; - import { auth } from "../api/client"; - import type { OgiriTokens } from "../lib/auth"; - - interface AuthState { - tokens: OgiriTokens | null; - } - - export const authStore = new Store({ - tokens: auth.getTokens(), - }); - - // Keep the store in sync with OgiriAuth - auth.subscribe(() => - authStore.setState(() => ({ tokens: auth.getTokens() })), - ); - - // Derived selectors - export const useTokens = () => useStore(authStore, (s) => s.tokens); - export const useIsAuthenticated = () => useStore(authStore, (s) => s.tokens !== null); - ``` - - Actions stay on `OgiriAuth` directly — no need to duplicate them on the store: - - ```typescript - // Login - auth.setTokens({ accessToken: data["access-token"], client: data.client, ... }); - - // Logout - auth.clearTokens(); - ``` - - In components: - - ```tsx - import { useTokens, useIsAuthenticated } from "../store/auth-store"; - - function Navbar() { - const isAuthenticated = useIsAuthenticated(); - return isAuthenticated ? : null; - } - - function TokenBadge() { - const tokens = useTokens(); - return tokens ? {tokens.accessToken.substring(0, 8)}… : null; - } - ``` - - `useStore` accepts a selector function, so components only re-render when the selected slice - changes — equivalent to Zustand's selector pattern. - ---- - -## Full working example - -See [`sample/sample-react/`](../sample/sample-react/) for a complete app demonstrating: - -- Login / logout flow with axios + React Query -- Protected routes with React Router -- Token rotation visualisation ([`src/components/TokenDisplay.tsx`](../sample/sample-react/src/components/TokenDisplay.tsx)) -- MSW mock server replicating the full Ogiri auth protocol -- `useSyncExternalStore` bridging `OgiriAuth` to React context - -Run it standalone (no Spring Boot required): - -```bash -cd sample/sample-react -pnpm install -pnpm dev -# → http://localhost:5173 -# Login: user1 / password -``` diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index e777258..0000000 --- a/docs/security.md +++ /dev/null @@ -1,300 +0,0 @@ -# Security Policy - -## Reporting Security Vulnerabilities - -If you discover a security vulnerability in the **ogiri** project, please report it responsibly. We appreciate your help in improving our security posture. - -### How to Report - -**Please do NOT open a public GitHub issue for security vulnerabilities.** - -Instead, please report security vulnerabilities by emailing: - -- **Primary Contact:** Project Maintainers -- **Subject:** `[SECURITY] Vulnerability Report - ogiri` - -Include the following information in your report: - -1. **Description** of the vulnerability -2. **Affected Component** (e.g., OgiriTokenService, OgiriTokenAuthenticationFilter, OgiriTokenRepository) -3. **Affected Version(s)** (version tag or commit hash) -4. **Steps to Reproduce** (if possible) -5. **Impact Assessment** (low, medium, high, critical) -6. **Suggested Fix** (if available) - -### Response Timeline - -We follow this responsible disclosure timeline: - -- **24 hours:** Acknowledgment of receipt -- **7 days:** Initial assessment and communication about next steps -- **30 days:** Target for patch development and testing -- **60 days:** Public disclosure (either when patch is released or as agreed) - -If you don't receive a response within 24 hours, please follow up via GitHub issue mentioning you have a security concern waiting for response. - ---- - -## Known Security Considerations - -### Token Storage - -**Important:** This library provides token management, but security depends on proper usage: - -1. **Never store plaintext tokens** – Always hash tokens before storing (BCrypt recommended) -2. **Always use HTTPS/TLS** – Token transmission must occur over encrypted channels -3. **Token expiration** – Implement appropriate token TTL based on your security requirements -4. **Token rotation** – Utilize built-in rotation mechanisms with grace periods -5. **Database security** – Ensure your token storage backend is properly secured - -### Authentication Header - -The `Authorization` header contains token information. Ensure: - -- HTTPS is enforced for all requests containing auth headers -- Proxy servers don't log authorization headers -- Client-side code doesn't store tokens in localStorage (use httpOnly cookies) -- CORS policies are properly configured - -### Cookie Security - -Ogiri supports secure cookie-based authentication with configurable security attributes. Proper cookie configuration is critical to prevent common web vulnerabilities: - -**Why Cookie Security Matters:** - -1. **`secure: true` (HTTPS-only cookies)** - - - Prevents cookie transmission over unencrypted HTTP connections - - Protects against network sniffing and man-in-the-middle attacks - - **REQUIRED for production deployments** - - The library logs a startup warning if disabled - -2. **`http-only: true` (JavaScript-inaccessible cookies)** - - - Prevents client-side JavaScript from accessing auth cookies - - Mitigates XSS (Cross-Site Scripting) attacks - - Even if an attacker injects malicious JavaScript, they cannot steal tokens - - **REQUIRED for production deployments** - - The library logs a startup warning if disabled - -3. **`same-site: Strict` (CSRF protection)** - - - Prevents cookies from being sent with cross-origin requests - - Mitigates CSRF (Cross-Site Request Forgery) attacks - - Options: `Strict` (most secure), `Lax` (allows top-level navigation), `None` (requires `secure=true`) - - **Recommended: `Strict` for APIs, `Lax` for web applications** - -4. **`path: "/"` (Cookie scope)** - - Limits cookie transmission to specific paths - - Reduces cookie exposure to unrelated endpoints - - **Recommended: Set to the narrowest path needed** (e.g., `/api` for API-only apps) - -**Secure Production Configuration:** - -```yaml -ogiri: - cookies: - enabled: true - secure: true # HTTPS-only - http-only: true # No JavaScript access - same-site: Strict # CSRF protection - path: "/" # Adjust to your needs -``` - -**Development Configuration:** - -```yaml -ogiri: - cookies: - enabled: true - secure: false # Allow HTTP in local development - http-only: true # Keep enabled even in dev - same-site: Lax # More permissive for testing - path: "/" -``` - -**Security Best Practices:** - -- **Never disable `http-only` in production** – Even if you think you need JavaScript access, find an alternative approach -- **Always enable `secure` over HTTPS** – Deploy behind a TLS-terminating reverse proxy if needed -- **Use `Strict` SameSite for APIs** – REST/GraphQL APIs typically don't need cross-site requests -- **Use `Lax` SameSite for web apps** – Allows users to navigate to your site from external links -- **Minimize cookie path scope** – If your API is under `/api`, set `path: "/api"` - -**Common Mistakes:** - -- ❌ Setting `http-only: false` to allow client-side token refresh (use a separate, non-sensitive endpoint instead) -- ❌ Setting `secure: false` in production because you're behind a reverse proxy (configure proxy to pass `X-Forwarded-Proto` header) -- ❌ Using `same-site: None` without understanding CORS implications -- ❌ Setting `path: "/"` when your API is scoped to `/api/*` - -**Additional Resources:** - -- [OWASP Secure Cookie Attribute](https://owasp.org/www-community/controls/SecureCookieAttribute) -- [OWASP HttpOnly Cookie Flag](https://owasp.org/www-community/HttpOnly) -- [MDN: SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) - -### Sub-Tokens - -If using sub-tokens: - -- Implement proper scope validation -- Use appropriate TTLs for each sub-token type -- Monitor sub-token usage patterns -- Revoke sub-tokens promptly when access should be restricted - -### Database Access - -The library itself doesn't enforce database security. Ensure: - -- Database credentials are externalized (environment variables, secrets management) -- Network access to database is restricted -- Regular database backups are performed -- Database audit logging is enabled -- Token table has appropriate indexes for efficient cleanup - ---- - -## Security Best Practices for Users - -### Configuration - -```yaml -# DO: Use environment variables for sensitive data -spring: - datasource: - username: ${DB_USERNAME} - password: ${DB_PASSWORD:!required} - -# DON'T: Hardcode credentials -spring: - datasource: - username: postgres - password: mypassword -``` - -### Token Rotation - -Enable and configure token rotation based on your security requirements: - -```yaml -ogiri: - auth: - rotate-on-write-only: false # Rotate on every write - rotate-stale-seconds: 3600 # Rotate tokens older than 1 hour - batch-grace-seconds: 30 # Grace period for old tokens -``` - -### CORS Configuration - -Properly configure CORS to prevent unauthorized cross-origin token theft: - -```kotlin -@Configuration -class SecurityConfig { - @Bean - fun corsConfigurationSource(): CorsConfigurationSource { - val config = CorsConfiguration().apply { - allowedOrigins = listOf("https://yourdomain.com") // Specific origins only - allowedMethods = listOf("GET", "POST", "PUT", "DELETE") - allowedHeaders = listOf("*") - exposedHeaders = listOf("Authorization", "access-token", "sub-tokens") - allowCredentials = true - maxAge = 3600 - } - val source = UrlBasedCorsConfigurationSource() - source.registerCorsConfiguration("/**", config) - return source - } -} -``` - -### Logging - -Be careful with logging to avoid exposing tokens: - -```kotlin -// DON'T log tokens -logger.info("User token: $token") - -// DO log token identifiers instead -logger.info("User $userId authenticated with token type: $tokenType") -``` - ---- - -## Dependency Security - -We actively monitor dependencies for security vulnerabilities: - -- **Dependabot** is configured to check for dependency updates weekly -- **GitHub Security Scanning** is enabled for vulnerability detection -- **Regular audits** are performed on the dependency tree - -To check for vulnerabilities in your copy: - -```bash -./gradlew dependencyCheckAnalyze -``` - ---- - -## Vulnerability Disclosure - -When a security vulnerability is reported and patched: - -1. A patch release is created with a security fix -2. CVE is requested if applicable -3. Security advisory is published -4. Release notes clearly indicate the security fix -5. All users are encouraged to upgrade - -### Past Security Issues - -None reported yet. - ---- - -## Security Features - -### What ogiri Provides - -✅ Token-based authentication -✅ Token rotation with grace periods -✅ Sub-token isolation -✅ Configurable expiration -✅ Hashed token storage (application-configured) -✅ Filter-based enforcement -✅ Support for multiple databases - -### What ogiri Does NOT Provide - -❌ Encryption (you control token hashing) -❌ Network security (HTTPS is your responsibility) -❌ Session fixation protection (implement via headers/cookies) -❌ CSRF protection (implement via middleware) -❌ Rate limiting (optional `OgiriRateLimitHook` SPI available; bring your own implementation) -❌ Intrusion detection (implement monitoring separately) - -### Recommendations for Complete Security - -1. **Use HTTPS everywhere** – All token exchanges must be encrypted -2. **Implement rate limiting** – Prevent token brute-force attacks -3. **Monitor token usage** – Alert on unusual patterns -4. **Regular security audits** – Code and infrastructure reviews -5. **Incident response plan** – Prepare for token compromise scenarios -6. **User education** – Teach users not to share tokens - ---- - -## Contact - -For security questions or concerns, contact the project maintainers. - -For general support: See [CONTRIBUTING.md](https://github.com/quantipixels/ogiri/blob/main/CONTRIBUTING.md) - ---- - -## Acknowledgments - -We thank all security researchers who responsibly report vulnerabilities to help us make ogiri safer for everyone. diff --git a/docs/sub-tokens.md b/docs/sub-tokens.md deleted file mode 100644 index 9dc47c3..0000000 --- a/docs/sub-tokens.md +++ /dev/null @@ -1,196 +0,0 @@ -# Sub-Tokens - -Sub-tokens are specialized tokens issued alongside the main APP token for specific use cases. - -## Use Cases - -- **Device tokens** - Per-device authentication with shorter expiry -- **Chat tokens** - WebSocket connections with custom scopes -- **API tokens** - Third-party access with restricted permissions -- **Mobile tokens** - Platform-specific handling - -## Creating Sub-Tokens - -Implement `OgiriSubTokenRegistration`: - -=== "Kotlin" - - ```kotlin - @Bean - fun deviceSubToken(): OgiriSubTokenRegistration = object : OgiriSubTokenRegistration { - override val name = "device" - override val includeByDefault = true // Issued with every APP token - - override fun clientIdFor(parentClientId: String): String = - "$parentClientId.device" - - override fun expiry(parentExpiry: Instant): Instant = - minOf(parentExpiry, Instant.now().plus(12, ChronoUnit.HOURS)) - } - ``` - -=== "Java" - - ```java - @Bean - public OgiriSubTokenRegistration deviceSubToken() { - return new OgiriSubTokenRegistration() { - @Override public String getName() { return "device"; } - @Override public boolean isIncludeByDefault() { return true; } - @Override public String clientIdFor(String parentClientId) { - return parentClientId + ".device"; - } - @Override public Instant expiry(Instant parentExpiry) { - Instant limit = Instant.now().plus(Duration.ofHours(12)); - return parentExpiry.isBefore(limit) ? parentExpiry : limit; - } - }; - } - ``` - -## Sub-Token Properties - -| Property | Type | Description | -| ------------------ | -------- | ------------------------------------------- | -| `name` | String | Unique identifier ("device", "chat", "api") | -| `includeByDefault` | Boolean | Auto-issue with every APP token | -| `clientIdFor()` | Function | Derive sub-token client ID from parent | -| `expiry()` | Function | Calculate expiry from parent expiry | -| `validate()` | Function | Optional custom validation logic | - -## Header Format - -Sub-tokens are returned in the `sub-tokens` header as Base64-encoded JSON: - -```text -sub-tokens: eyJkZXZp******************MFoifX0= -``` - -Decoded: - -```json -{ - "device": { - "client": "app.device", - "token": "abc123", - "expiry": "2025-12-25T00:00:00Z" - } -} -``` - -## Managing Sub-Tokens - -### Retrieve - -```kotlin -val deviceToken = tokenService.getSubToken(userId, "device") -``` - -### Renew - -```kotlin -val newHeaders = tokenService.renewSubToken(userId, "app", "device") -newHeaders?.let { response.appendAuthHeaders(it) } -``` - -Renewal is rate-limitable via `OgiriRateLimitHook.beforeSubTokenRenewal()`. Implement this hook -to enforce per-user renewal throttles independently of APP token creation limits. - -### Revoke - -```kotlin -tokenService.revokeSubToken(userId, "device") -``` - -Revocation fires `OgiriAuditHook.onSubTokenRevoked(userId, subTokenName)` when at least one -token is deleted. Implement this hook to emit revocation events to your SIEM or audit log. - -## Custom Validation - -Override `validate()` for custom token format validation: - -```kotlin -@Bean -fun notificationSubToken(): OgiriSubTokenRegistration = object : OgiriSubTokenRegistration { - override val name = "notification" - override val includeByDefault = false - - override fun clientIdFor(parentClientId: String) = "$parentClientId.notification" - override fun expiry(parentExpiry: Instant) = Instant.now().plus(4, ChronoUnit.HOURS) - - override fun validate(plainToken: String): Boolean { - return plainToken.length >= 32 && plainToken.matches(Regex("^[a-z0-9]+$")) - } -} -``` - -## Client-Side Usage - -### JavaScript - -```javascript -const response = await fetch('/api/auth/login', { method: 'POST', body: ... }); - -const subTokensBase64 = response.headers.get('sub-tokens'); -const subTokens = JSON.parse(atob(subTokensBase64)); - -const deviceToken = subTokens.device.token; -const deviceClient = subTokens.device.client; -``` - -### Sending Sub-Tokens - -```javascript -fetch("/api/device/action", { - headers: { - Authorization: `Bearer ${deviceToken}`, - client: deviceClient, - }, -}); -``` - -## Rotation Behavior - -Sub-tokens follow the APP token lifecycle: - -- When `rotate-on-write-only=true`, GET requests don't rotate tokens -- When APP token rotates, sub-tokens with `includeByDefault=true` are recreated -- Use `renewSubToken()` to rotate a sub-token independently - -## Examples - -### API Token (Opt-In) - -```kotlin -@Bean -fun apiSubToken(): OgiriSubTokenRegistration = object : OgiriSubTokenRegistration { - override val name = "api" - override val includeByDefault = false // User must request explicitly - - override fun clientIdFor(parentClientId: String) = "$parentClientId.api" - override fun expiry(parentExpiry: Instant) = Instant.now().plus(7, ChronoUnit.DAYS) -} -``` - -### Chat Token (Short-Lived) - -```kotlin -@Bean -fun chatSubToken(): OgiriSubTokenRegistration = object : OgiriSubTokenRegistration { - override val name = "chat" - override val includeByDefault = true - - override fun clientIdFor(parentClientId: String) = "$parentClientId.chat" - override fun expiry(parentExpiry: Instant) = - minOf(parentExpiry, Instant.now().plus(2, ChronoUnit.HOURS)) -} -``` - -## Troubleshooting - -| Issue | Cause | Solution | -| ------------------------------ | ----------------------------- | -------------------------------- | -| Sub-tokens not in response | `includeByDefault = false` | Request renewal explicitly | -| Token expires too quickly | `expiry()` returns early time | Check expiry calculation | -| Sub-token not found on renewal | Wrong name | Verify registration name matches | -| Header parsing fails | Invalid Base64 | Check `JsonCodec` encoding | diff --git a/docs/versioning-guide.md b/docs/versioning-guide.md deleted file mode 100644 index 592edd6..0000000 --- a/docs/versioning-guide.md +++ /dev/null @@ -1,408 +0,0 @@ -# Documentation Versioning Guide - -This guide explains how ogiri uses **mike** to manage versioned documentation on GitHub Pages. - -## Overview - -**Mike** (Multi-version docs for MkDocs) allows serving multiple documentation versions from the same site with a version selector dropdown. - -**Current Configuration:** - -- Version source: `.ogiri-version` file (currently: 1.1.0) -- Provider: `mike` -- Default version: `latest` (alias to current version) -- GitHub Pages: https://quantipixels.github.io/ogiri - -## How Versioning Works - -### Version Management Flow - -```text -1. Update .ogiri-version file - ↓ -2. Commit and push to main - ↓ -3. GitHub Actions docs.yml workflow triggers - ↓ -4. Build docs with mkdocs - ↓ -5. Deploy with mike (creates version directory) - ↓ -6. Update "latest" alias to point to new version - ↓ -7. Available at https://quantipixels.github.io/ogiri -``` - -### Version Directory Structure - -Mike creates a directory structure like: - -```text -gh-pages (branch) -├── index.html # Latest version (from "latest" alias) -├── 1.1.0/ # Version 1.1.0 -│ ├── index.html -│ └── ... -├── 1.0.0/ # Previous version -│ ├── index.html -│ └── ... -└── versions.json # Version metadata (used by version selector) -``` - -## Configuration Files - -### 1. mkdocs.yml - -Version provider configured in `extra` section: - -```yaml -extra: - version: - provider: mike - default: latest -``` - -**What this does:** - -- `provider: mike` - Enable mike version selector -- `default: latest` - Show "latest" as default option - -### 2. .ogiri-version - -Single line file containing the current version: - -```text -1.1.0 -``` - -This is read by: - -- Build scripts -- GitHub Actions workflow -- Version management scripts - -### 3. .github/workflows/docs.yml - -Three jobs configured: - -**Job 1: build** - -- Installs mkdocs and mike -- Reads version from `.ogiri-version` -- Builds static site with mkdocs - -**Job 2: deploy** - -- Uploads site to GitHub Pages artifact -- Only runs on main/ori branch pushes - -**Job 3: version** (NEW) - -- Runs after build and deploy complete -- Uses mike to deploy versioned docs -- Command: `mike deploy --push --update-aliases "$VERSION" latest` -- Updates "latest" alias to new version - -## Usage - -### Release a New Version - -1. **Update version file:** - - ```bash - echo "1.2.0" > .ogiri-version - ``` - -2. **Commit and push:** - - ```bash - git add .ogiri-version - git commit -m "chore: bump version to 1.2.0" - git push origin main - ``` - -3. **Automatic deployment:** - - Workflow triggers automatically - - Docs built and deployed to https://quantipixels.github.io/ogiri/1.2.0 - - "latest" alias updated to point to 1.2.0 - -### Manual Deployment - -Deploy a version manually without pushing to main: - -```bash -# Install dependencies -pip install mkdocs-material mkdocs-mike - -# Build documentation -mkdocs build - -# Deploy with mike -mike deploy 1.2.0 latest - -# Or use the helper script -./scripts/publish-docs.sh 1.2.0 -``` - -### View Version Metadata - -Mike creates a `versions.json` file on the gh-pages branch: - -```bash -git checkout gh-pages -cat versions.json -``` - -Shows available versions and their aliases. - -## Mike Commands - -### Deploy a version - -```bash -mike deploy 1.2.0 latest -``` - -Deploys documentation as version 1.2.0 and updates the "latest" alias. - -### Deploy with message - -```bash -mike deploy 1.2.0 latest --title "Release 1.2.0" -``` - -### List versions - -```bash -mike list -``` - -Shows all deployed versions and their aliases. - -### Delete a version - -```bash -mike delete 1.0.0 -``` - -Removes version 1.0.0 from the site. - -### Set aliases - -```bash -mike alias latest 1.2.0 -mike alias stable 1.1.0 -``` - -Create custom version aliases. - -### Push to remote - -```bash -mike deploy --push 1.2.0 latest -``` - -Automatically commits and pushes changes to gh-pages branch. - -## Version Selector (Material Theme) - -The version dropdown appears in the top-right corner of the Material theme. - -**Features:** - -- Shows all available versions -- Shows version aliases -- Clicking a version navigates to that version's docs -- Built from `versions.json` on gh-pages branch - -**Content:** - -```json -[ - { - "version": "1.2.0", - "title": "1.2.0", - "aliases": ["latest"] - }, - { - "version": "1.1.0", - "title": "1.1.0", - "aliases": [] - } -] -``` - -## GitHub Pages Integration - -### Required Permissions - -The `version` job requires: - -```yaml -permissions: - contents: write # Write to gh-pages branch - pages: write # Write to GitHub Pages - id-token: write # For GitHub Pages deployment -``` - -### Branch Configuration - -GitHub Pages must be set to deploy from the `gh-pages` branch: - -1. Settings → Pages -2. Source: Deploy from a branch -3. Branch: `gh-pages` -4. Folder: `/ (root)` - -The `gh-pages` branch is created automatically by the workflow on first deployment. - -### Automatic Deployment - -The workflow only deploys on pushes to `main` or `ori` branches: - -```yaml -if: github.event_name == 'push' && github.ref == 'refs/heads/main' -``` - -Pull requests only trigger the build job (validation). - -## Troubleshooting - -### Version doesn't appear in dropdown - -1. Check versions.json exists on gh-pages branch: - - ```bash - git checkout gh-pages - ls versions.json - ``` - -2. Verify mike command ran successfully: - - - Check Actions workflow logs - - Look for "Deploy versioned docs" step - -3. Clear browser cache and reload - -### Docs not deploying - -1. Check workflow permissions (Settings → Actions → General) -2. Verify gh-pages branch exists -3. Check Actions tab for workflow errors - -### Can't deploy manually - -1. Install mike: `pip install mkdocs-mike` -2. Build first: `mkdocs build` -3. Check git is configured: `git config user.email` -4. Run with push flag: `mike deploy --push 1.2.0 latest` - -### Wrong version showing as latest - -Run alias command explicitly: - -```bash -mike alias latest 1.2.0 -``` - -Or update in workflow and redeploy. - -## Best Practices - -1. **Keep .ogiri-version in sync with release version** - - - Update before releasing - - One source of truth - -2. **Use semantic versioning** - - - 1.2.0 format (MAJOR.MINOR.PATCH) - - Makes version ordering clear - -3. **Tag releases** - - ```bash - git tag v1.2.0 - git push origin v1.2.0 - ``` - -4. **Update docs before release** - - - Update CHANGELOG.md - - Update configuration examples - - Verify all docs build locally - -5. **Keep old versions** - - - Users may still run older versions - - Keep documentation accessible - - Delete only if necessary - -6. **Monitor versions.json** - - Ensures versions are properly indexed - - Check occasionally for consistency - -## Advanced Configuration - -### Custom version aliases - -Create multiple aliases for a version: - -```bash -mike deploy --push 1.2.0 latest stable production -``` - -This creates three ways to access version 1.2.0: - -- `/{version}/` - Latest (current release) -- `/stable/` - Stable version -- `/production/` - Production docs - -### Development version - -Deploy development docs separately: - -```bash -mike deploy --push dev development --title "Development (unstable)" -``` - -Makes dev docs available at `/dev/` without affecting stable versions. - -### Scheduled deployments - -Create separate workflow to update docs on schedule: - -```yaml -on: - schedule: - - cron: "0 12 * * 0" # Weekly -``` - -Useful for updating docs from a separate "docs" branch. - -## Integration with Release Process - -### Release workflow - -1. Update version: `echo "1.2.0" > .ogiri-version` -2. Commit: `git commit -m "chore: bump version to 1.2.0"` -3. Tag: `git tag v1.2.0` -4. Push: `git push origin main v1.2.0` -5. Docs auto-deploy with version 1.2.0 -6. Create GitHub release linked to tag - -### Rollback - -To rollback to a previous version: - -1. Update .ogiri-version to previous version -2. Update latest alias: - ```bash - mike alias latest 1.1.0 - ``` - -## Resources - -- [Mike Documentation](https://github.com/jimporter/mike) -- [Material Theme Versioning](https://squidfunk.github.io/mkdocs-material/setup/setting-up-versioning/) -- [GitHub Pages with MkDocs](https://squidfunk.github.io/mkdocs-material/publishing-your-site/#github-pages) diff --git a/examples/spring-app/README.md b/examples/spring-app/README.md new file mode 100644 index 0000000..1b569c1 --- /dev/null +++ b/examples/spring-app/README.md @@ -0,0 +1,15 @@ +# Spring Boot consumer + +This independent Maven application uses `ogiri-spring-security:0.1.0` from the local Maven repository after the root `mvn install`. It demonstrates Spring Boot 4.1.1 and the native Spring Security resource-server pipeline; it is not a library-owned authentication server. + +Apply the schema from the built core JAR to a disposable/local PostgreSQL database. Set `OGIRI_JDBC_URL`, `OGIRI_JDBC_USER`, `OGIRI_JDBC_PASSWORD` and a non-empty `OGIRI_DEMO_PASSWORD`, then run: + +```sh +mvn -f examples/spring-app/pom.xml spring-boot:run +``` + +POST `/sessions` with JSON `username`, `password` and `client`, plus `X-Requested-With: ogiri-demo`. The only demo username is `demo`. Copy the response's `Authorization: Bearer ...` header to later requests. GET `/me` or `/sessions`, then DELETE `/sessions/{id}` to revoke an owned device. `/admin` requires an administrator role, which the demo user does not have. + +The application explicitly owns password authentication, credential delivery, origin/CSRF policy and authorization. It uses PBKDF2 for its temporary in-memory demo user. Replace the account directory and login workflow with your actual application. Do not enable permissive CORS around the JSON/custom-header sign-in exemption or reuse this demo as an account registration/recovery system. + +`ConsumerTest` runs against actual HTTP and PostgreSQL with a one-connection Hikari pool. It checks lifecycle, role denial, tenant-safe revocation, rejected credential transports and distinct account-directory failures. The example rejects duplicate Authorization headers before delegating parsing to Spring Security and permits only ERROR redispatches so an original 403/503 is not masked by error-page authorization. Its database is configured with the `OGIRI_TEST_JDBC_*` variables from the root contributing guide. diff --git a/examples/spring-app/pom.xml b/examples/spring-app/pom.xml new file mode 100644 index 0000000..e9a76ea --- /dev/null +++ b/examples/spring-app/pom.xml @@ -0,0 +1,15 @@ + + 4.0.0 + org.springframework.bootspring-boot-starter-parent4.1.1 + com.quantipixels.ogiri.examplesspring-app0.1.0 + 170.1.0 + + com.quantipixels.ogiriogiri-spring-security${ogiri.version} + org.springframework.bootspring-boot-starter-webmvc + org.springframework.bootspring-boot-starter-jdbc + org.springframework.bootspring-boot-starter-security-oauth2-resource-server + org.postgresqlpostgresqlruntime + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/examples/spring-app/src/main/java/example/ogiri/Application.java b/examples/spring-app/src/main/java/example/ogiri/Application.java new file mode 100644 index 0000000..99bc0ef --- /dev/null +++ b/examples/spring-app/src/main/java/example/ogiri/Application.java @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +package example.ogiri; + +import com.quantipixels.ogiri.*; +import com.quantipixels.ogiri.spring.OgiriOpaqueTokenIntrospector; +import java.util.List; +import java.util.UUID; +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.*; +import org.springframework.security.authentication.*; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +/** Runnable integration example. Demo users are not an account-registration system. */ +@SpringBootApplication +public class Application { + public static void main(String[] args) { SpringApplication.run(Application.class, args); } + + @Bean PostgresSessions sessions(DataSource dataSource) { return new PostgresSessions(dataSource); } + @Bean PasswordEncoder passwords() { return Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(); } + + @Bean UserDetailsService accounts(PasswordEncoder encoder, @Value("${demo.password}") String password) { + if (password.isBlank()) throw new IllegalArgumentException("Set OGIRI_DEMO_PASSWORD"); + return new InMemoryUserDetailsManager(User.withUsername("demo").password(encoder.encode(password)).roles("USER").build()); + } + + @Bean AuthenticationManager loginManager(UserDetailsService accounts, PasswordEncoder passwords) { + var provider = new DaoAuthenticationProvider(accounts); + provider.setPasswordEncoder(passwords); + return new ProviderManager(provider); + } + + @Bean OpaqueTokenIntrospector introspector(PostgresSessions sessions, UserDetailsService accounts) { + return new OgiriOpaqueTokenIntrospector(sessions, subject -> { + if (!subject.realm().equals("demo") || !subject.tenantId().isEmpty()) throw new UsernameNotFoundException("Unknown account"); + // This demo's immutable username is its account ID. Real mutable logins need stable IDs. + return accounts.loadUserByUsername(subject.subjectId()); + }); + } + + @Bean SecurityFilterChain security(HttpSecurity http, OpaqueTokenIntrospector introspector) throws Exception { + var nativeBearer = new org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver(); + return http + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(routes -> routes + .dispatcherTypeMatchers(jakarta.servlet.DispatcherType.ERROR).permitAll() + .requestMatchers(HttpMethod.POST, "/sessions").permitAll() + .requestMatchers("/admin").hasRole("ADMIN") + .anyRequest().authenticated()) + .oauth2ResourceServer(resource -> resource + .bearerTokenResolver(request -> { + var values = request.getHeaders(HttpHeaders.AUTHORIZATION); + if (values.hasMoreElements()) { + values.nextElement(); + if (values.hasMoreElements()) throw new org.springframework.security.oauth2.core.OAuth2AuthenticationException( + org.springframework.security.oauth2.server.resource.BearerTokenErrors.invalidRequest("Multiple Authorization headers")); + } + return nativeBearer.resolve(request); + }) + .opaqueToken(opaque -> opaque.introspector(introspector))) + // Only this JSON, custom-header sign-in is exempt. Do not enable permissive CORS. + // The native resource server separately handles CSRF for explicit Bearer credentials. + .csrf(csrf -> csrf.ignoringRequestMatchers(request -> + request.getMethod().equals("POST") && request.getServletPath().equals("/sessions") + && "ogiri-demo".equals(request.getHeader("X-Requested-With")) + && request.getContentType() != null + && request.getContentType().split(";", 2)[0].trim().equalsIgnoreCase("application/json"))) + .build(); + } + + @RestController + static class Endpoints { + private final PostgresSessions sessions; + private final AuthenticationManager authentication; + Endpoints(PostgresSessions sessions, AuthenticationManager authentication) { + this.sessions = sessions; this.authentication = authentication; + } + + record Login(String username, String password, String client) {} + + @PostMapping(value = "/sessions", consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity signIn(@RequestBody Login login) { + if (login.username() == null || login.username().isBlank() || login.username().length() > 255 + || login.password() == null || login.password().isBlank() || login.password().length() > 1024 + || login.client() == null || login.client().isBlank() || login.client().length() > 255 || login.client().indexOf(0) >= 0) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + final org.springframework.security.core.Authentication account; + try { account = authentication.authenticate(UsernamePasswordAuthenticationToken.unauthenticated(login.username(), login.password())); } + catch (AuthenticationServiceException unavailable) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE); } + catch (AuthenticationException invalid) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } + try { + var issued = sessions.issue(new Subject("demo", "", account.getName()), login.client()); + return ResponseEntity.status(HttpStatus.CREATED).cacheControl(CacheControl.noStore()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + issued.token()).body(issued.session()); + } catch (SessionLimitException full) { throw new ResponseStatusException(HttpStatus.CONFLICT); } + } + + @GetMapping("/me") java.util.Map current(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { return principal.getAttributes(); } + + @GetMapping("/sessions") List list(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { return sessions.list(owner(principal)); } + + @DeleteMapping("/sessions/{id}") ResponseEntity revoke(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal, @PathVariable UUID id) { + return sessions.revoke(owner(principal), id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } + + @GetMapping("/admin") String admin() { return "admin"; } + + private static Subject owner(OAuth2AuthenticatedPrincipal principal) { + return new Subject(principal.getAttribute("realm"), principal.getAttribute("tenant_id"), principal.getName()); + } + } +} diff --git a/examples/spring-app/src/main/resources/application.properties b/examples/spring-app/src/main/resources/application.properties new file mode 100644 index 0000000..2fff910 --- /dev/null +++ b/examples/spring-app/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.datasource.url=${OGIRI_JDBC_URL} +spring.datasource.username=${OGIRI_JDBC_USER} +spring.datasource.password=${OGIRI_JDBC_PASSWORD} +spring.datasource.hikari.maximum-pool-size=5 +spring.datasource.hikari.connection-timeout=3000 +spring.datasource.hikari.data-source-properties.socketTimeout=10 +demo.password=${OGIRI_DEMO_PASSWORD} +server.error.include-message=never diff --git a/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java b/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java new file mode 100644 index 0000000..3d4a598 --- /dev/null +++ b/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +package example.ogiri; + +import static org.junit.jupiter.api.Assertions.*; +import com.quantipixels.ogiri.*; +import java.net.URI; +import java.net.http.*; +import java.time.Duration; +import java.util.UUID; +import javax.sql.DataSource; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.datasource.url=${OGIRI_TEST_JDBC_URL}", + "spring.datasource.username=${OGIRI_TEST_JDBC_USER}", + "spring.datasource.password=${OGIRI_TEST_JDBC_PASSWORD}", + "spring.datasource.hikari.maximum-pool-size=1", + "demo.password=test-password" +}) +@org.springframework.context.annotation.Import(ConsumerTest.OutageConfiguration.class) +class ConsumerTest { + @org.springframework.boot.test.context.TestConfiguration(proxyBeanMethods = false) + static class OutageConfiguration { + @org.springframework.context.annotation.Bean + @org.springframework.context.annotation.Primary + org.springframework.security.core.userdetails.UserDetailsService faultableAccounts( + @org.springframework.beans.factory.annotation.Qualifier("accounts") org.springframework.security.core.userdetails.UserDetailsService delegate) { + return username -> { + if (username.equals("offline")) throw new org.springframework.security.authentication.InternalAuthenticationServiceException("Injected directory outage"); + return delegate.loadUserByUsername(username); + }; + } + } + + @Value("${local.server.port}") private int port; + @Autowired private PostgresSessions sessions; + @Autowired private DataSource dataSource; + private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + + @BeforeEach void emptyDatabase() throws Exception { + try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { statement.execute("TRUNCATE ogiri_sessions"); } + } + + @Test void installedArtifactsServeTheFullSessionLifecycleThroughTheNativeSecurityChain() throws Exception { + assertEquals(401, request("GET", "/me", null, null).statusCode()); + var login = login("test-password"); + assertEquals(201, login.statusCode()); + assertTrue(login.headers().firstValue("Cache-Control").orElse("").contains("no-store")); + String bearer = login.headers().firstValue("Authorization").orElseThrow(); + assertFalse(login.body().contains(bearer.substring(7)), "Response JSON must not contain the credential"); + UUID id = sessions.list(new Subject("demo", "", "demo")).get(0).id(); + assertEquals(200, request("GET", "/me", null, bearer).statusCode()); + assertEquals(200, request("GET", "/sessions", null, bearer).statusCode()); + assertEquals(403, request("GET", "/admin", null, bearer).statusCode()); + var foreign = sessions.issue(new Subject("demo", "another-tenant", "demo"), "foreign"); + assertEquals(404, request("DELETE", "/sessions/" + foreign.session().id(), null, bearer).statusCode()); + assertTrue(sessions.authenticate(foreign.token()).isPresent()); + assertEquals(204, request("DELETE", "/sessions/" + id, null, bearer).statusCode()); + assertEquals(401, request("GET", "/me", null, bearer).statusCode()); + } + + @Test void invalidLoginSimpleCrossSitePostsAndAmbiguousBearerTransportAreRejected() throws Exception { + assertEquals(401, login("wrong-password").statusCode()); + assertEquals(403, request("POST", "/sessions", "text/plain", null).statusCode()); + assertEquals(403, request("POST", "/sessions", "application/json", null).statusCode()); + var invalid = request("GET", "/me", null, "Bearer og1_" + "A".repeat(43)); + assertEquals(401, invalid.statusCode()); + var issued = sessions.issue(new Subject("demo", "", "demo"), "phone"); + assertEquals(401, request("GET", "/me?access_token=" + issued.token(), null, null).statusCode()); + var duplicate = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/me")) + .header("Authorization", "Bearer " + issued.token()).header("Authorization", "Bearer " + issued.token()).GET().build(); + assertEquals(400, HTTP.send(duplicate, HttpResponse.BodyHandlers.ofString()).statusCode()); + assertEquals(1, sessions.list(new Subject("demo", "", "demo")).size()); + } + + @Test void accountDirectoryOutagesRemainServerFailuresRatherThanInvalidCredentials() throws Exception { + var login = login("offline", "irrelevant"); + assertEquals(503, login.statusCode()); + assertFalse(login.body().contains("Injected directory outage")); + var token = sessions.issue(new Subject("demo", "", "offline"), "test fault").token(); + var request = request("GET", "/me", null, "Bearer " + token); + assertEquals(500, request.statusCode()); + assertFalse(request.body().contains("Injected directory outage")); + } + + private HttpResponse login(String password) throws Exception { return login("demo", password); } + + private HttpResponse login(String username, String password) throws Exception { + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/sessions")) + .timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json") + .header("X-Requested-With", "ogiri-demo") + .POST(HttpRequest.BodyPublishers.ofString("{\"username\":\"" + username + "\",\"password\":\"" + password + "\",\"client\":\"browser\"}")).build(); + return HTTP.send(request, HttpResponse.BodyHandlers.ofString()); + } + private HttpResponse request(String method, String path, String contentType, String bearer) throws Exception { + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).timeout(Duration.ofSeconds(10)); + if (contentType != null) request.header("Content-Type", contentType); + if (bearer != null) request.header("Authorization", bearer); + request.method(method, contentType == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString("{}")); + return HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 4bd65da..0000000 --- a/gradle.properties +++ /dev/null @@ -1,23 +0,0 @@ -# Gradle Build Configuration -# https://docs.gradle.org/current/userguide/build_environment.html - -# Java Toolchain Configuration -# Gradle will automatically detect and use Java 17 via: -# 1. .java-version file (recommended - already configured with "17") -# 2. JAVA_HOME environment variable -# 3. Auto-detection from installed JDKs -# For local development with Java 25+: Set JAVA_HOME to Java 17 or use toolchain auto-detection - -# Performance optimizations -org.gradle.caching=true -org.gradle.parallel=true -org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError - -# Kotlin compiler options -kotlin.incremental=true -kotlin.daemon.jvmargs=-Xmx2g - -# Gradle Java Toolchain Detection -# Enable auto-provisioning of Java toolchains (Gradle will download Java 17 if needed) -org.gradle.java.installations.auto-detect=true -org.gradle.java.installations.auto-download=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index cdbee6c..0000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,12 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/40b4344c056b4284246d176d9701577f/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/9e87f9444e29ce8efb3f66e8435d94b4/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/40b4344c056b4284246d176d9701577f/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1050b2216f8beaaecc1289b17d30b586/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/3ac7a5361c25c0b23d933f44bdb0abd9/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/40b4344c056b4284246d176d9701577f/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/54001d0b636ad500b432d20ef3d580d0/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/dd5b582862cacd4b8e0d82037f92a53f/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/69a793dd932268c7d1ae9d8b855de8ed/redirect -toolchainVersion=17 diff --git a/gradle/version.gradle.kts b/gradle/version.gradle.kts deleted file mode 100644 index 4f72509..0000000 --- a/gradle/version.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Applies one lazy version provider to every project. - * - * Precedence: `RELEASE_VERSION` environment variable, `-PRELEASE_VERSION` Gradle property, - * `.ogiri-version`, then `0.0.0-SNAPSHOT`. - */ -val versionFile = rootProject.layout.projectDirectory.file(".ogiri-version").asFile -val fileVersion = - providers.provider { - versionFile.takeIf(File::isFile)?.readText()?.trim()?.takeIf(String::isNotBlank) - } -val resolvedVersion = - providers.environmentVariable("RELEASE_VERSION") - .orElse(providers.gradleProperty("RELEASE_VERSION")) - .orElse(fileVersion) - .orElse("0.0.0-SNAPSHOT") - -rootProject.allprojects { version = resolvedVersion.get() } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index b1b8ef56b44f16b14dc800fa8103a6d89abb526f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48462 zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc> zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+ zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE# zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~ zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4 zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85 zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$ zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL zpk^@B<+I6imc@7vip za%1jMB7q@1j# zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4 zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=) z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5 zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^ zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC zs@!crc0=128PM=Zp zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF zFa}-nM8X=K?Jy02*o02@6k{ z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4 z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5 zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<EY>=Xin*nGX79k6vQ?beRk zy_J>@YSC_gMIG$yjO-y&o>S6xtfT27aSs>e|`x(f2R1bM}*518~%x>1Yct=18b&Z>GiS*>VB$+i2876zL)1cT zN33g=g|>xWE2)dds5m2+8Vy)m-u@NHOlGYxxjam21r1;xWtT0TgqKZrl}*LSkqFt4 zNTI1=3o%C*!-i;iWnlca$stRdwITA1?#fD~5OIqIQAM18BwO_u>hqL&OAANiF|8rG z_IZ9mp?FA-{Gq9+Ky<#NgL1gWJixfO0ziP$4T4G>vsvqC-NQh+A64F4! z-(t<=AbPSG%`mTl6BJtH~3RmvPhQlE-EUkEoBIP(_WMN zK~Fe!siee{M*ns1hkp5(2}vX#%u+T!Abh=<_gEx_QW?h4V@B>uOCEetEe01tl)^`V z(=cOLmuOB;8&&m%_6pcyrt83UXkJ`f9I&0KxY09}RTTs!l^_7~8$tPA%Hm#&$k0;# zF;O0zCGo0IN)X~SyKDoY1DW{Ulce|V9w=ld;U`z$t$>8U!Gu8V?_LAJAudt3eI#*! z2i9~F=kP5m>!bmb%1e~b1!1gz01Py(Yw5gOsFN#o1a&d|=PpgN(#UVreY9^99I0iG zaYE@>(C^V7pnoB~#w$2C1_TIb1N5Je&iao?S2A*TF>@vpHg`31{uk<9{zf_}s&z%dL-Fo)C$yl$%pAdqU!HJgp zh_{m1imk{&{ScyeuziqZHu5cto0{S}^BlXu% z0~;>_yHGd#?Kt8ErxK)z6ojj5SacQobw)-8`c!$HOI*V6eyqou{1Upm%_p!BY^t(D zDtn(oQ!jff`ddGSD;P8Hes!v)OKW-*>mS&#i0ow87;h>(=Cu0>b4)|=EegbN5=Xkh z9Ge13=3z#sk+fT<)PuUUf_%Nx@l!P?t*mni^94p^Ax6b2SVL5U>9dHH!H4DL4}@?@ z?Gpq$C**OmWliYA{5s<|EZ@QI2{-K#brFxfA~AIqq&-WSALHWQ8}%mvaNFasrtnE{ zg=sB4-RF!?)nf{>Wo~kNFgYefoFHBcSr*;iF9B!R=5Np|jv>Uf+mcarG-XGy*kP{z zISVyoPcl_9cOg-@613Qx16OGF#sH&2NTHDa_}vyidmxS~pMfY#AeQvu?AXpWNzi7A z*6&7a7!C9HRU+N{>WYTh0GXoBnXw{lQby^XShgDOw@e8TP}9Y*oFV4MVF#@Ds2A+A zXBEt3a@-IIl)TOcXx;0P;|ihR%Tq@DXeG5p-O{!T7Sg$s1 z8OA4iOx-!>6eK^x{jU-0SvByimK|nZik5zKIvvWVGE)4=x^&5Nx%Qgje!k3VoizaB zip#?$u(R8u{wUFC>tVR8oA%7fs?xEu(gYn>y6BB%vwPR9&RoZE%%RK! zl#Qnkl^+Y*Y4L{Xk(YX&aGj|zSpqO_;C3CTepA!L#4EXO|(eA`Fi+2EQ3!C zo^SpVP?{chQ3uaxu7y>w213e22cdA#l-M2kStPE%sq6vE4M*?3At!S7tIp(tQg(Ml zECjeJw8)*#LYYk_+Txv3rxsH9jJZBRrHp29yJ(^;_PEdn%#U1q`r89}38;XeF{ee& zsZEsUbJ{LtwOjU{vjL(Wvs2!Bx;#^Mzld&TjS@oo3kk=0P36MC-Ie6eHNN&{8b^s z0@jcbdejrrj!>r#Wu=3H1dgjeOI}NkhmE}K+UK&M>%7b!n&{0Zixk%^)6#@=V~IZN zxG>9kl&STQth}qScidfg58d2dF|v_U<@+V^eE@$4x;7oS3)MvWusA?9+%rN>aY#eA_6 zic@S(@e9$9tQM-&-7>X8~#n{5G}nuOu=dSyN+b~jA;_SExZ1H9Q1A}}Rz;XtXUIOP0~ zZzS|~T+%de-nGI$s?wxaJoe+99vmo%xm8o8SNEsAqAE)4LNvHc-1AX24C4k4u3vZmov^_VcxgGxapV(8)_K(^8= z2d{xCrmk(x&514Ly?e{Mf6}h3=oeP7+ZE{%B^c-kK8g0W{tYw3q%zty_Rd@1nbnyHMwabNp-sSyzpV4v>QsnKcQjF67%g~n&3t^1MesVxCzfJ5b=SOI#YfPP^^JGQw=9L1RCMFbrU{8O0LWOUdBK#j&{`tzXX zpe2_{+-8$a+o#%8MUlL4$yK`*--z&3{@Y?jP!m{g5nM+Ht=bD3o}Ok~sBQ_!^!->! z?NDVtyLXzmGYCEmjSCDK*q?Aq1;8fz9l9|z@~l{)R6GfKELc^(nV+TjjI^n0M+S0i z@YOu*Tk>|M6a0_n$(E;#^1Zgif<-CpYiMvyT+Y*9Z?&~IKSwsLa5Q#p_?FqK3lKIw zlp6Hk%lio6)yq>m-`QT2Nj-q!aX7~Hlm^Xh6FNbw z$#ri(Kk*GUHXORu@`aYQU@ zB~S-oIO^~abRPocemkm!W73dbb!j^_xgo_@#W#6p12>w^{){VfeX?U71Xyn9&E zHa1#*!4c;?r}jv7dMN`g#&R_S215)dccDOJr=uz%LIz@zia+LIFjRakROr?P zQ|Xw0Pa8o7&W=fw17`+SqepsQ-Os5v3ncD5|N?N(AHH&`>hLY+CLOluJ z_ErpaT49zK(UcdNmQ%iA-`jS`A_1c|$W86{d_T_T2V-HH3xUqpX0QJSH%i>1i>#vK z&y{;5)^pMB=u;&_DEWakQU>j&+opIrBf~2GUh{`kG{|Z&2Z}5dwG}>Y{W_uQHaR$_ zYH%}$c`CGC-FGCetRdQ@RZ2-%ucC_|R?mHzYEnqC%u9zRBH8wx7po`=EVPMpq+hL2 zTdjVhQn$)++17^cn;<3=bxJy0Z$U;i3AqJMPJO&SuieU&0eVX?eLEEI7Av@#PV_ZQ zsa>I>B5HE996O$z6HyJfhEt^aC><@AnzeN`xs@lv>^pPFtcodrcGyqPSB?#C`Piu0 zh5=hAW|OtT9hs*G?7}@*mG_f7ae@-Nz4{qvne66kco^uD$(JbCo2ttqUm-SMy@kx% z!eDt?5>w5)M!E#C!b#Iu9GqyhUs|QoYWHtR{4espRS-LUt=viY2iygF=-j3kcU#uF z{ka2=zsOuLR}s;&PbbrB`zty&NfZpV*Y;~i*W$EH0JOGS&FMS%VK@)f*%OOrcU3P9 zq4zjhMpx}oc`PWtP!o5Bdlp=(A***TZwVwuZbuB1Pibv5uiHvW{PsE-k5IfCgUz~l z0nMeZU0R>(ajoQ0G%Il)z0BgRR*bsdz5NcqJ<)niF6|PUO0i}<4)q>6wx4K(5>Y_I z4$WMkbCOQFs(krBnl zx85i0*7%Zm(&nKNP?AQ}d~6@?D9dO%@}ouN2paSR;zyUqJuw)1SRy=g%o;g(BD|Bh ztnKV(4fcBgDJ~M@%}n-6ow3xOhnC>C^d?PbS(9=TnO)k5p+W;pu2F4eiG7ts zJVL4M(NiZPQDy*9`H>-P0GWY#=UTnh8feiNF}hCs`8^ZDKy;XIL^9K4Ps&y^#DQSE z-?J z@YOQ9NQi>ZP>^ix5K`R07kWj?`R(B?E*OyR1$Vd;8p%2Y2zEYt4CJM~gVX%MO(E1B zzXhsHn~R1ifq9~dtzuH!*3&W;r`D(Sjrc)m#EI%`Car;CMWcU0c+0r?O!)HpjEvyP zb^;pO-Bn6e-+>dS^o{q&8yEH9v}vuXX`W;NPRlwJdX|59`z?~z{pFE!^u{3k{KkJ55^ zD;F0ldy9W*`d5YP|0(E6|K%}9|D^SIq>wO)4^cJ+yCa&xl*3}hpvcQ1eP_k;@>tz= zOZnw)#fxHc81jPcTM#)jgy|0?n0(jd3IPu-lJ&Tm`#F1)o$GTwYp@dlqy-qiHFCHS zKgikMUx|%x=_%B)>n_y^+HvD2=nP`}-G_0A7)I$yc4`tXS-On8qOkNp>Q^$|Ew%Jm zYx34*(*Z3SF}xw$CA?nG9O3ZH7l)@Dp4EyH>8eXDb}AFz)k*T53iA~gRu&e15u@|% z9Rw?69nQOeJhv^^unjd-VGFwbDzf9K{i(U{xxHyM@-aI+0qP{TU0G~w+Fs>taL#Ik z4+92(Z7n%+okd478;__0GkE`&(C`k8h@?UNnM=F%A~2|TKo)q9F<5`s)KwxJRw~k; z4giS~|8AIVG;rde6I^W6m9fliR^7YT*>&x7wv^?xu(5p45n{|2F>x%?9Jq+~Tqo9# zChbeGm@9!(s;uIKae_4h@`~yIj`Tqct+-M>d>~2PCiQ?UmFUioyy&~h_DTBQ--W|q zqA^UaJMTz4tEggQ*_cQ_LA7j7bLyz8#cpGggy;YBVk!%oSdufoh5-FYAQ)v=d$Bi`G$^~ zm!O;En#M9uCykPzLZ5SHa%?hDHP5P;T4HN0L6J*r9DAvC1WWPOrd{*obfr3yJ?Kl3 z^_6dnXRoi4<$Tr!=4mhHg6ig~BatHR zv%ZMJr-`8w_JyFEzUSQdp0HT>|9QQG?IXj$7Rbx4E)%HauDyY!tedHP ztIbq;D)ckd-eirAHOG7icBH23*ApHA@nG*Jdh}~G?L5C^Xw^+nLWG+>hRi&(fnpY5 z?^hj4si6I{m1u^%i_yk$tco}28X8|}g5*tAEZYF37$f(+xT%XvO^`i^Ig}%cydrwF zlpL!xdO->&@q|8MiJrAxt;z2CP*a+EvV`_2& z<1=p{zjhmmYVkpx#RV=#zuy&7^2Trn=H$nT{OBVF*0z|QH!NxBF%gbqT!BEx zKB!SsSUwSo1Zr?kMM%N)@hG=&m`vRQ6QK6=oIvnUI+|C)dGKM@jNwqG2Xi8;YCUHYRh? zbl@DN-za)+0F9kw>Yv=ioL)01uFp7@AVEB0AH-nmB%j$RC_totFy4BKd;OPCMUMBb zu3oUUK`|{AvkM+@KPZD4Tn$(VlQi&aWV*Uf@DO|FQjLOoVw&C@z~Um*h%Ka-C=n4H z@(Lf&MDJXNS{3Hs@J)11(zo9tGp>wS^b9{Q1WN=Ktn>ZieRZS?k`gb7P4n?cl^7^* zG5-oARAG#i<*z`J0ski%;QCLD-T$AbOHq<{KxIb4=QJRn@MGj=ns0WhZX+uX z=oTjz`o-VviMt1mB0W1vA*7oq1ENz{<*-EU)U;r*ODfV!G-?hdnzhM@rRZ=|qaFTN zX*t~$gc-)M7GS{#34R-n`B)eAPfebN46~61R?j^(Pg3TXR1PyQrO7Mf@xf<3VL0`4 zh(i?-SktJu8Oj?KIy4p@%5ZH;P&p5LB8 z^}7P)9h}vUP+1Hd3nNzNcbR`%1>dSZbWhiXe-CcB+s9e)_w<{bypZ(@cQT`P@ch=d zSOPhExgI31MVFPsClEXe>$~qYQ+d}7(!BE*9y%AjQ47BMDt=#>`1ie)|ES{pFFdHa zI)CK`f3x>)DtZnm!f5=e@g;3iK^jf!RU6hpjYu^V#q0uWLuJ-6={Ua3gDi9#*P7;- z`rm*5)n{2QE{UZ01PVy@_9(amogzzOwYcVgp2>LsJ(}hKbX_!ayZ7=U{!p{BHussVj(W z2z3$zu7h$KK<%}P0YBJ+)0unV*xD&6GusXqs=M=Cl&fP@Ttzfq?>H9TW#qDId+C7? zhD;;HOxDJR4dc_xI7-b6N6nZ@bUWueDk<_9Rju2I*o(i)M0&~%C^ zc)a<25M<^NrsjAccydV2HJu_-1W>b;xrB~Mi@c7FrW-94$-GnKXvF7( zA68!d!gkIo8(URS{(u{zRtrF}B$9@*)KH9POqOW-B$za4Sg-A&PM*on$>$o#L7pH~ z&YW8oJX3T!!@2r4Rr6ac0ZDbtB1b5yc$5}7oZSDvGF0FWTpZ#r7@GfM^MmC-p{9Qj z_JmmlTxO(^(NHqBc$ECU$jQp^;)%xnyr$qvNTd`R@j$8JppDCGQAHQ7?fja9McCUZ^;``VW$1+G#=<;K{_OfH- z_$fp~S3K`;jPNNZnkB@=DFQy3{6+Bq9nOf3~dr4q8zD_t{P4-^%<4kj!U z0aj`=#@G*w?!4fpM? z8Pwb15(Ka*TtDN-2aWK>*hh{R_C}*e*vSTkHdM(ETM!JrJ=1h?(_WL}2p#QXjrKZ_ z0k_yu^;~)#*r>sQP7d_4VBRvWJCzw#TxA{*hktwQI3ST{8{>3$KHJIgMGK6I!d}Q zinmfq&RLRxX8P)_@@vVr0gPu7*)uU<%xS{|Eg;*w1}2=C&?7B zSX?OLt-gZO+<4@tLeF+K0~*|xwMD__KxWgGfsUpj)KyeCM3J-f*uxe|xk;Dlqq%1< zL(PaY@U(>Z#k!C!B45JlmE^~wHSH;r1c^kWTG9_VT~1LN6$a6Yg@kNF?&b0hs+5Dw=0j zR(wcEYmdfgojx+Hzu89*C}4$I7^?^vYKhF(`>=MC)VeeFR}}?j#XeLnp8OhW9%9ND zt6utD8DHnQj5@YJv+$USdN{8apQir2)Z{8_s!BABmG2O#pz5lSh|gf#CI8X4I|U4g zhQwk=VEV+j+-KNxuIk96Bi%^(Sf9}A7o$zHJ5mV~)qP))QQY&^>9}z9z9)PWpw>8T z7#NWNEtnUoUl{DP5(lmy<3;tpLJ3hG|;CGB`3**uH0tf9>;7w;Aq9SRVg1FDpI5y~rY#B|eCNpAXD z9692@_%$t2^nu&4lU~(~_iVf|Cs|mXs-xKlY$-~FZB$!oDK#)JgHZCG)ySDURM=@(i zCpd{Er89|l&)(&5>L6LuWY3yC6)`jPz(Po8pY=AYIBnx3y2Qx6*sT42mpR$zwx!!< zHHCc~tbF^-bje?bo#~Q59Dmw_-VcliCn^FfI*EV)U1NkNA`6Cm=^%j`%M?1Zxa=1U zn#DPNc32&XHHfUfmPx*J+3_GA&g-_pd#wO=Q^5bdhzmm)>s@yO0q|>ROV(hkhJWf@ zqWjI#+9Wx%C+!kp&kxX|XPS5m9CBC&3r>}SwdFd#YF_W78A*CN6mFC)qzOjM);Z&v z#MjdXXMw63v*tbvY+$tDmuHNFunOlRM#qe|eV&|$98!xy{n)-=N?lrkr0_}U^sz|x zs0y);(2Dooa;(9zHzRi=I{GSVcv!6jl%ck@)>JODfR? z%aI)0HvbhzY9K7eYsntq#JvWzj$WCuoyGoPY7;LSPfZlFiWU)X?(-p}s4FXQcpIp00;%Jv;k0t@2vBu4i;rh-?{z}cHTLL9Rz zT8r(1Ws*H~EyH+adP$cGv|7HkeS9p6eOEI*`idH3twkEJ*72|ey4JgISglGV0Vo@qe#)f-=|g%l$S&Onwl@mmdn|sjXXYaQ4MlfzjiK1* zY&hWQyc9?G2}2s1fYnQ}LXpq{!&Kr97d?=a?_xXAU0SXrZE?T+=9os2*v9%Csph*M zW{}m4+PIRmHEI;<=c5$PMrfg#MTs);4Tb_0**o}*cimSWRcxo(;G&&NV+-?W7v*%4ACG#t5J zQP=$g-(mN*;B6s)d9JNkF0#Zz_WA>J;{=2a!IJsiqCV!YLjJ(wUJ`3b$>qcZ!HjDT z2xm;fMSbtJ|3o~tc!jJ+U8a)vX@NcxU8y#u!Puq%R~{sps0msRFO2!GM4}786S7* zxgNmf{q@|Sdnf6_he>gEGX7Hn)uih5nL&&t4`O{?V;;bdl1U~9RAnjNmt~1UPC3mh zrR8ZtHzz1(yOYSK$OjKf;InJ+7mH$WfqI^OG3dhA+S!YmIgRv>2H78?<6A=~%E{ug^P+^b*+f=j32&Nv&Ypq?DcH&Busg^AUDE|p; z8(tQxZs1+0gUX<5~Ah zT0cGckI5%nM~d`uaMJ$o%2bt^##I0UdaQ2>-bpsP4P1Vk8r7EOSr+a!D*Z4shiKFL z35Lvs^i;#;G{%ksUUo8(Nj2DY?u5->J8kqS_#{B`HqS(UkzR|K5&6XI_#FH4?$ znMXeTb$nmr1`|{n*#5H1T%vtU4-H)vrtAchme!ZG#@c+Hrf4uxx$;VU(Dr~N-ich4 zMKpdwot^bPY#kBILFgi?i3W_kV%vn2J+%R5x}TL8I?B~o#VXlmr?i=y`yJi-><;X* zPCDrsU51x;mkr+t18lPs=6)r^gEh2$saaA!qv_< zKQP13J}ptHaUjT_(*x+P}wfV-}57aU3rp#3AB&~e3%y}0ju#22u5@mUIT!GA{* zd%-e2DTmr#$(P6^$&N0oCgR)F9IPR~!Q!x6YI*7dx6LR6n8tj(#1~!0rofeMtT#g* zW%-p@V09>&o>iz0j66K^soJWg(o9#T(8Xx-P3?;J|t~nIDSGPq(?-B zOoNnc5HZhsW(m6!J+yj~kjmjV6GKvhO>%^v5`O2I@4B$Z!~DgelYWdC4P>YfmI$TR zq`atDEhIt5ua)PS;Yz1`FX@3Na6j^uBx_rNKTmgboWGwE6O5;iQiN6Q8>ZX%ApVJS zTEf6oj=@?7klS(JaijG|(gO@dTgxB3#H)4&?+@VWkTc)dl;qK|uv;WRI*cG2`6PiF z4+svy+Bfn&Fs57Jz6i!C(w$w@VWPAbRGak~oN>3vUg|Mmk0NpfURt0*DSJ_e*Gi8I zqshW4F}L&aS8x~4*#{4vOc`gKW99cx*L^69fgPj#?++q9LidItd}<@&#E{ZGz7g|c zFX$uKJ;Qv^NpN*e&EL;l@1br8j8oxO3e`g<911L_jr~Xb0)t$x$A~dFay9(}gt4&L zyb=1<`|)_7(!^xJ14xLBGKXO3`R^_;F01 zG70TiF<5(=pRsJYj!^XjLl_vFJOQPhN#Pkr#G0-m#xG>q)GAHjE4WFhe7Zi83;gte zdDv6+)qrgh3F0}$gPmtb9-Ff1m|xDD$6jX)Dcd5Ms-(@nKM_3)2+hfh6@Cs@-=%Z_ zIinf|ck6rN{EOadGmJ-rzvxZnAL)(mf108HL2v&m)%=a*?3CnX2ZfOQY?ha_11m@UzRqlkhrVbQ@0M(tSSTerx}IH@Dn2={w$iGqU#`v}PuV7I&A9JYNP%sqMn z1bTq*Ok{V>SlVH8H*4X-lO?VzaDQzAaLvc1tTL+To)YOuj^V8mQ?)K-FT(s_!ds-O zeb$rKRR-~g^+_aiGtH6kbJ)!K^ie;ipJ8e;>iy2}73i(1RY-~!(tk2zPj;pwB4k1a zVa~7lF^EE`UH=#eb**88zBH%!WkO0S?_Zu0KpRtXN+XMsAwfT56IZI}&cs+R5N~p3 zlQH7o$(zsQQBPIRmD)i>TfdcgCSKbVVD;VCmO3l1VNbV&rWc9o>Pk>ex!)Nap%NtP z&kKIFMm@k9-HeXj2$((SmG+a-dXvl7q(7n=8)cELHf!@Le+X)=++(}pKC*dcns?>G zVa*fV{2FDIJNaK_jq)WE9MvxiTm6sI%YUn|S=oP0Z`vE#GMZa`4V5byxmv0@8@Zb~ zyBOJuTAG>Im^uIL@!ZrWJy6xL{%n;pEwY87Y^xYSfmmgRcgcEDfz4TJ#{;n|g>8(> zv$(RLnp4oD1Mj>H@ar|0RCy}E{GwvuKOf1FS}O&z-Q)MmCVEK{p~b2xFj@lTn}#s4xg7h+r;n$TZDlT2AXAv z7R^$J?R|*xL^>7HI}e>7{HszA#Y_e8=~8*3zy_J$ejuhByeI0I!w-&%MW7Q-FGMKU z8qPm&IdU3w#^#`d%Vcn&q^w;EEr|w2F@ax^`R;a@p>l`U-T%~f&^`#zG}qdSV)A<0 z^*U=#=#o&gd{o+*s#j$xf+2y^t1Wj9_h}(DNi^aK#jI}z)v1rk-H)gocbgc`wB*?$ zfg~22r!^VEN+n>U8|3{Ebe#!9k|dF8lV*9c&9H~&g|$Ymc-2O^j9w$Q^I)ldd}5zv zQkBFDS2TxDn`p}-{-`br?tUCgyfr0Wbf3QeATbp=9sN|e90U^eVOu0~VT$1A5))@C zPcwzUn7bP^Gd~hLA@8EwiklMmlc^(;uPE%tLecC-iZ$_~jNJnZYn1A%r}=VE(-LG; znh6Q+b;zKz_N7)0SH7t~u#)e>Pr194w7xp;V&CpmJw5j6zBO%yB zjVf*iveYaWlrE~+p8YYym=-QmTd_F!`)ATishn6(oD}hTE2AqnVPF_os`ca^ET@@Z zoo~4YJASOBn<;8#(#3G>n1E)&@JA^3LV7mK^kaJ$((~ASWup3G(%#8O%xFX8XSiN~ zUF0&gDyT`FzIjtA`<-+9RXEKbwu%RtcrG!#-aoN0aj)i z(G|=#b_!z{o1}cIyw#n=j~Ac|NnR@<-CW$c%JFBFTi5JW0BX#4k2o2w{L0EglSN7E zFUcmFVF&U6NBA7!t`Lut>faDk>pW>Lz9BSzsqWvnI<+L#wg=zw+aeL6=70S773#Rq zG@fVM9=1ZibB`>L>hKz>rHG}`pX;dZD>I!_x~u>jsx3;0d$`Q%t7d<8^lkl8w0WZ3 z(HGiok6h^#G2EzIH}G*;!U8FW>@|C+wE+z{@e{wwWEkzUEiT0aDJo2JwZR{zcX$Bz ze2pzE&vKCc6@vE*GIv1LZ=qSg~HR)Jf|ljt#^m2hZF4z|32*7{hd|u`C7{C zjG>}`{SC3Dnc~5%D4yBa!V@}xSBtQ$ZWY^qs3)9jTuIXYMgPF5E0*&A0B(=JEntcVgC%ZO4UKHyuzuSblKNHWJ}OzVpeS z?8|{P8FtkJ=~%YMf1h*@o-YsZkLVQU!43cY~nWEmBt#&Ar%7WClZK8 zSe-!M)B8((tj^wSIm3?e5oe&mQs6BAE#Y7K*^boU^Z#aITL%-H zul5Gx*FKM}n~RnE*Ko3}nXrk8nTw0Ok-d?{|KMda<$n9cFHzkfb4wa&Dp0x>XjayP zg-KZ^Ayey*gb`NecHls@$a-2|Z!Xe^@P`uYYo`Q*jKzDQGPFf^GDQ5rd(-X3n)&f|bD>?`-DktKL<0hWK!cPS>L^@|VH6## zG*0#NtGfzpZpt+e{yL@K$|Lg*JfO%I+hp&kR;NxOJ+y2H49xZA7=^RKObPZi6 zL&R70!l_{PTFcxI#h+WsO^Y<`hE*z1vg9n7nG-6n0xBU8F8yDd}=?${Kl$qim3(S98@^W*vvSs{l zU}!oUIXap-i#nT`er(?avm4Q4-snuM&-cwu#-M{K8n;l1gP$ z3sw?`ls1z%eb%&mNBvLuEci8}-Q`|kUw6;F0-pHb?+A)+BLSn7_@my}6u%J=Ub~(* zU1n~wcfO|73IBZF;|Bhy$0FeO^>lmmZz?ZuZC8$p6<>B{Lsp-*mS05IVU00ergKWv z(LIsLS=?(>QLLQQ?bdTpyO?iiEL`;>(XJw^lA*7FCd|$g@c3VRy#tUf-Lfs*_HNs@ zZQC|>+qT`k+qP}nwz1o`ZNC1_y*J{2=fCentcZ%LwW?M`<;L&dcdwa@4GT@LCkltq=Xfy+OasOLT!lXrqy` zEW9YuDcfQtJ$oJ|Ln|b|q*_a|YPgCbBBfQ|5;-1(P3R`sK~3T`TtVV6yrtDbioJKI zPDV1BAaj#O~V^ll>$# zNC?nv_r5RiH^A2t<)qzcvns9Qd$_UU$`jN;KUSNqMCQiCFCi3A$*D#(v=FXCqz$SB zyC8vjHyJhMy$5kCi}FBy0NdSCJa6{q(|*9I^zwX1NHX*dHOIDB8bsI3_{(*-kkQV@ng|lWd*nWx!(xQ1stGMcRDjH=YUQvY2^uCZuO%-0Jw5az*F1nW_|h zR~z5DT4j&Z7527|#z9b}pmRW}p^|OrU(TWox^&Kn>YUn%%JlZJ^16vzy|O|GnZsf3 zSXEMjOhuYZlh*ikE0&zHt5va@6&GI{1&D+NPop@Tss&f!V4;}nqX@iOvdonoDa}J_ zE-u%qrrUpYVYSGU5NeXJr?#B#3dkObD8uk*U|u*zS;T2YgAk;_kdF0s4A6A*YGO4)#dKwYLQi+*i=C3N85d93 zAe#Lng7EX?@}-FPvIdp0y!`J@^1tg|IHwZ=C-i6LW7u!d>#==7<(?=6?caFCo;)AM zwwV6XHIU7}%D3 z75#&7SiVq=f6k4N*gy{?o~K9`+fsId8Co*62ksPHLm=SB>G)@44I(Fbs1stfE==|e z5WM)k7Hs~OwT#*$%<~0|BEb_6HV0F0=kYy;P zdAZbN(@{*9FL}4bSi-&#J^2;N`G{J?KFD@i^8BEXQq3$Q#~shvw_cx5r%ZlgHz2&Y z*cU<9UD1(G6qg=Yx{LRix``xh^Yi7@j|r7hm00t{(0ei78ZQbt`JV={$XlXvX91YH zxbI<;-YQG@9xrY>Ar~yWklR>hQ-X6TUxD-S!;~b9lu;Tu@f59S=euifnkTO2C*G;S z@TJZ5{$VG<^ThBbq_74=9q9r7DxC6VBngr@olJ}~W87-NEagn(;M*)7Oj2!(TG+}U zsLu!TV4B7DH{}gtanAHawLkpH5_$jk$0~;0`rM1Hjkl;4D-KsjXTl<*z|E`_8Nlb6 zroi&vNu(socja8wZ}9J>;D}esqgs4BR?_u7ZyELz2k%GQjtG%Vx+yeS&QI*AK1Q~e z;1-8)WjT?WqB>et(n%42u5UPI+!F^B7Hx#oW{i;??}{9#vpvk}lwvHPB$=-+pnIAL zGBd3sTO%TRGFw?`Nh>DzU#VeO7C?`w!-QT4ZgBE!WsS1clJ&i=m$ zHn^;?BNx^_wESMCsSKfxi542WFvUJUh%GpT-JP-b+D|wh`H$h4?*AT6uKyK)=>%&^oOXr5Al10+ld z9x<66pEk?hlV|$s!otJ~_Kz3DcB~XFzWq<@HMwvNFc2}VQuS$6g{U$+nN4G0`E zua0)-H1D8k;mm6E{(!pNomCz*qxv$pI3NvG>(+Q4AcJvK#K8 zb9SOKS@GC!pN|JW#<}*37GFj>D1wi~_)k#-N5izNy0%(q7hMm?oL_Ju8jMFGA9bKb zv$!gbC9lC0>Unx?+*3GF(6ZZH<(4j|5-Om02Y2z2IG_&xn+2Z`6;N1An(~^lQwwUQ zOiKj)?fuj7EGlb8nv@wDs4us&o=Bt%l*TAhB{h=R+Pddpm83-ms{V0T&ofYt=D7dS=Kr=V{~wzR|1=j_+3Fh+3mcp0J6k#Z&$+yVt*OJ$s$BYK zRx!5u|IH#%N;9@dV#r@$o(;Dy3GBon{2-)SK+R!>`0yL(nq~lFeelQy_)_BZt2i}m z8rSXb0|MpaMQpG<_IaUCD@=+=`KtLmC}H1)-vV;8Y!fw&`K2B6oou$QOj%XL`Ye$dX*5~GV? zjoCc8{4m*B_lFn=K@#mp@(*Vga>;sjA3Ds|(a_aGGbuFi)9-z>)&hY^h=PM>jvvAt z$Q7Zfbr%lPeu2OFHW3uNyavs`ezAXnB`OuCGx+U1e%!gwF?S3T3XLaG+BzOfiLB-f zLsTI!R2nT{#3)Z+EHpqiKXE$CK-~2S!*Tvgi)l{*o7SZiuHQf&N=jK$gt6|+nF)`Gm z!Txq?dNfctW^}=z-436nDud8w974=Iuf~cqED93ykXqf1w8FZK9fiO>iyHhGH6`Xa zy99CYP)x3@)FSqPdVt-Br1$H%x6;EwpuBzZ?#_D^RUI0KPMzf^_Q2rPhK)0jFB8Xm zlV*;2seylEHqM|s4!E5>k-zx$17R0R2*LcwM(ea^%K>Rf92id$mc6SChy+Lhh?+zh zvO6({dx7GOFjsuW1#TIks9C3Y1NS^K;IL#Bmt5WRAnNcc>QhlO{Vj2vmon)s*asQd z33&IEDekAAXHibwHHW4Kjin6FB;UgbL))#+*%fRgjq!Uy)J$xt^A4P* z=wpGU$DPMXW)DL%DW!nu39E+G5tKB@YM$r#?rOf~PwEaIWOZ?-rZteokPGZsqWYS4;B z|0LjjIbp)2Q9#;HApIi0rAAv&MKYgXU3KhsoOYe|YT)zr{({<}EXL67@nFgE$g8n) zlwsHK7H3m?1l)9j7MVEeKIFU&$Urel=||l_I+%2%vpEWGJ4%Ae=4~9emV-GN((dey zu%{X&7)-JZ@$2L0Yqtni7;-H%fWs%8= z=kT2S6oOA<-_q!hTShh=6tYB`my{cf^+Lx>yzS~3hAy^=8Fn4^M9*a;F$7-pPb`5WTTi>BH<(hQt<2d>L}bEO@qeR~R5CV6M#}U~hOs$t?sI z7o&N-naKA!$TJ z>&^XTo(>zGjv|b*XTI$ut5?7&&KtRH*Xif1`>gBEp7*Joo(B{{&6%EYr?;2euFLC6 zyxINGDCvA&Z9Ke6+p?I9Q!BMcUI`b0h}(?yqWH@VsM zQOR!?^5j*fLK3_B=$34i3+r{u7IgD)M~W2q7y3L-307k;BupXtBuqlRxD3=-rhwa9 z?bS^@iS*Hnd^;p2cOp}nC~VDSN?;3$3z!yI^$)`1W?UAhtCjjqn>M&ph0;8EaiL{z zu|C4KQm1Ko&6~iXk*x&^ph_a+*qDsevtmcT;T0k>1Tvc@2_|YU#phijBjGm~(FAS> zlUlF>J!lV+cX^mbgNt|q+%c)}o#I2L8tL)BII4PpHABevx1oqq4Fk=enLf)lPJppehzt;iO9UQ2qK{ycJZ}25$Em8#QCj@IGeY)Ih;t1C_j5#Indn9> z?q%Mr*&t<`FGYDnXUw!Q9F(&(vc=j2NyA|}`{O%(aBk4&ic|F*CyG^zcJTh7Jbkku znj-MdZ0aPz3?=kXncCW=-<;dP;J9T1y-C;{aJj^)J(P2N6H-0wO?ZvS=U!GHKVCK< z=aWv?u%5>H&8MwXa49`eLmGW<%;nt}*#2=)K*`axE(dLvH|fGa6F34#8tRY?cr_y0 ze3Ys0rp;JgADiP65s|!r+v;Bhhv}`Vm{n>M24Hc%zOJ&UhG2A;(vSJbsM4>fU{u2_ z-6VIhEcV`qxROML_k8tmxBr)-{ z0Nki4Ka!>@`U^UZ)eJ*+dVEKh%hU52puWKbEG44AD>zWsBPQobQCa)OTlz41wS`U5 zA(_e!#MIkQ_D?<^L@2G~TpSiQGc{2i*D?M}9=ed6<%52)rPN_&_Zz}kJyQ*xrss+n z+*}R)Uzw_8MN}8>Nin$jkrHrz;R3n*HT*JD&M9fIRS?wRHq#A#i(f4q5+z;_5Ij)k z55fi>(u^$A=GCiS!o_k6hWVWf;@9>(C^LB-^lw%JYn+7v`}UC04jw=#dbI?>PxGb< z^hYM;a|^$Xv8HwRyEFBlC0EGDeVFD zsI=F15ChE=aHP6tL~Ao9#WHh`H@ZcicgWiJi5Wg12JkaFg6%fLuw^#2^+FGSBYJC) zcLQaBfXhJJeIf<*h>U>kVP9*cRCfKc<$@qO~wd*)<>-)SK6P zJ@I^4#us1Hf$yt#&=?VaIkhDY^^W;!&OFd#L5S3wEK(42b#OVRSI3Yn=DLC>djb3m zOx*FMX7ymI4;B56>=L7Cv?Opmx_j#kUAIX{b-S2c8Z$v=gOMvo?-ij^Qg7+-IsiMdRFM)v7G{O9O zb{zD!lmDA*H)}70ZFQ4xTkLM$F*jknM@CK!9fA;1rEyA1T;kT|rRhl7MQ@3Z8K3<$ zthbXo^c6w1sy3usEhrD|+wtJ{DqW>!SzzMAYG&n5P_48!FI7^!mt^UsJ=Ii%VFz|f zC`{_0n8zVxPB%8P&U9wpG3=awF3lq(pY)ZY+X0iPX>u?nXvOVKqHlZ!kPr!p?==9sB_~DS`Wz) z-C{l?ZU7>v`xhem*b=STWhZXwe7a@WUN>CeYu(sj2^yMe+X__p(O0XKfx z%AXEQxVFsfTzy)ozm#eCQhr*;4iF$jVCn@40VgXeH%1E z29UQ3y$aVZ3TOp-E~*g`Gz^slv`Lf|RO$MFBa@P)tKRuI=cc?XxIqzmXgmw~OWv_3 z79M~sk*g{jtNxD4ShkFGO@d3`N{)-(L`+B$P3o{T)|L%BE`c71nj=koezdtBY4~a%t^5r3-m!3Kj%V`9dB?v%w?BxOI$&~!jUNWa z@o8Q~I6n%f3*aDLLYK<|4FU2X@*``7jnlDRq5+VebLwb4vJVL_1XDYFTUc;$dW3relP0}p?81NZ&{!uRJU{&9)O%uEL4Mkts~ z&T=;)Kjl_c^Tc3YX*8y9Lb`*cpyU^wFHkn{Z--k1SA~|n0bO2_YwyEVv91paW(>>D z5A?fn$`0!!94mEWTUFmE5+yocu&wZDj;aE3+jOFJ95*T%`pKWaqKNiaixt!T^#`@p zHlA$6Fj^5&7!Hb19 zHyE9zQWe<12XmH)8IDIOtwPeM zHRd&LKn-qMRQRtyy5LYzR9#*8JDBD2K-E^^INa=#S{XA+rW5XKtg>7Nn^Of&Vhir! z+P>KycTUF|e~Hw_vAX%ap<+u9o9)jcAVaw~|4zkmS zZa8>nl~i|D8zjQ^%<{;ZR6cbVD>%?nlBzUD&(9h}VOpBkVW!AuVW!MGuz;OfTWE_| z{yi!0mE#74$DH%4$iv357s-5PS(g3aXJUS?=I-+Jz4Y{Czu2{VMepL1!wV0l8b0k) zSH~&|HJ~YYm{WKY&gKO*WNzB=l|JE3C?T`VIh$Fi$wHFx68QWYRy%ziF%z4Zc<{>B zjkGSyv*i{+F*O@tKQ!EDM%7xw!z{Yx)~Woo$kr{Z7+t7ve;X$MoE{R-LVe22TZY;% zOIFYRqSw}4;Mcno^z?O*G8Q`&wbgNV%>E*DX{fnqK*lP#K0dvcU3endLW%GugLOH< z>Y{oG#ECe$UPvO#$t@?@GA5JFE*6oY@?+$jRxnx(BiZ8q{AuRkwymR+;{*D6-bh*) z-5@PC8lo`?K**Ec9*n$U>OJRjK0H$J@vnMoQZa4ti zMegzJ2oft=1Y+aEG$4JE9{t_I{tH*SwKVixk$IyL|hvQq*qu&_4C6X zp>36)v+qAXl|OfXL8koN-RrhNjjA36)N;pjmTkOO>jg}c>35j<2gH)fb7QYv#8VV2-AXJ1-O{Vpi$uIz3lMp3dl`?Wwpp>|6_$}|ROmbQ- z+O3VID2pdMNR%dc(_#%+-P-%bNIb5Irk&d>rOY(_mq8%P;dkWuH0mR4vhl=r?rV5g z%=n2Yz2%@f5#I6!(KxF>D%1-3IyJU|VW-!(l$}cWBQtobb>#9D+>HlD>@kp+qgiCj zU_Y+2nP+9m^gw~vIRygs?R~aXBZ*Vk8cFZj_&b8(pTaY{Y}cTT z*fRuKeL3=89rk16#2TNQ%KL}Ryx)%5M0MHy=A(uL9M*f_;^wBL-FO~J+@|(7I)GQF zGxu8y$fzRDE)xoI0MCR3S^FKd3Mzir$&35HZu)9V$~5*Kk^r{%vt!7ISD#%fswRS1 z7x8ugQ&u(usOPXbN5Z5URhEFc|NLc;g}f4JzVjlUxu&$T#yH-Omy4s=$~b=B<)v}= z;R7RHY}oe#TExRVjM2_)jF*Q3%G{)3ZZqgSTa^}wnjk_InITrx)tW> zN_A5pLZ9CogVv`5^1_9Jm_n4I&Od-1kC6YSPp-Oxyt0!D zIplg&zC_?4NKvoQui_?BUY3EYOP5n0W0#hYf21a%4Fg1xeEs;w-CE2d_X6pd9A`2e zuiIRY)}Lqe0J(eXdpq{`UG}5w@h=I2qwDlnybY&n3-F)3(mWK*z~Y1=sqQ352UCF4 zQlI=T^y5Lp>gG~>1T94`()}Z4=w<|*zIWTL=+#(!PT$k6nPOoI-RVk#s?iWB=$tTc z;v`#9_oLoCy7W1j8Mn^hfr?}kDKcERb3jxH4>hafqve(?N%m6{o48;*Aj`VQb5)Ul zHK-31_Fm*+OH8EXSzh8{$7fljqN=ahTv<75(Rp-SR$Zz#EMGFOcXfT5%J^HHx8x@r zP2)nIWHes~>%OVy%4>O3(0{X?N*ukyQv5>kKb>M|32-D&p%1(V8j7s?3w|Lp63nOV z937ts^a~AioVI92W$?353}~XMK~{A}5JkKH5b=n9Ciq@IDBAB;Z!IUAV+ciiDvH*j zMD^3Dk+a${QM5$azio{#f^OHOx>LnJ+5kbRm4^N`5ii4(4>XD|b?3s1jrWv1Z}MFy zT9v+!?Ds9SiLUpcRnr?JG+C=^SKkC=BwXt~F8Tyir)=)czcAl$Z)2R5pR!H;e=OVl z8*}D=$~ONscK(|=^G~^sSitaqkw<2U?vov$hY7)fa=I8~62|7IuK10w(qZq9BnSjK zt$S9yI^QU{77(-&cteiu27n8-8*tNC&-dMPS#upD2hi$Q=J$O0#Os?xwTN{WtSzZC zp0+5nsTrDO-C3RykP7Y)6z8U{uiQ@973Pg|STBrbPO4R4VU>jA3ZJD%OK)mD`u%Bq zjUA|-$B9L(11X}nY*naJ%@8ESe`WsFWU8vR= z2;2}9@)$?_zbc_riw26%Kg!e8Kd<=z-OEDxpIr0*^LqcyFQ+uzy_6rD_)MF*+Au)L zK+sV!gc8RX!}1A93BeHY86igj>{s@tCS@2Inb@Wg|3Ir$G(TxPHZ`*>y-_zsskEEv zlcqu`YL%;Yn6XuOyEIg6vQ;HLymz>grb&Gw(*q#A5?6USh=@|D2=%(`I*cmsk7f^9^}}P? z?OW5EW$5ivagZURMyiQ!)dSTd0?Cq6Pu{r&OKRfiuu+&nj(M|bhppFk4ze_}sSz1;);PvKNiaE=q^G|5w^Vy2SN zBs0Xts91C^d0dq<=JmXesd8D;1K5UvF9?WTYl6d%lJqXxN`Pj}5LxPgSRE$%)Se9Nn;^;MLmXCiH$)23AiNRlj3 zB5S`@U11=y{xj(rqgS3zSUD^dhUILAwb|IZt>UN#gv=Rm63ig{MK*6HQPQQC{?1ODO*flB7}Q(AO3hFI}(g&O+0tS_v* zssss=fjAF6c7M%h{bJFcbm>-<=R>Xa4X{qGb3|a97zk+R8pO+p(k2^QM<;%(sz0y~ zRB?%#!Lct8vXEtAzqvF2#xo$NsieLB9TCSs^E_?X{@2BD7<@uv#vvJzQhJD^v3!dT zl|$vIA|g+p5nMz|Au5{UAyp|$2kfI)S~hhN0%yOnr(#(o-&bKg$Y+VeF{*sx3Du~N znZWwrE{QHx{GA?2J*uLTQ+AKA)Nbt+N2AXvftlF`pev3SOJ$4`MSDf=HiGkA5i0UO zd~$T7PLbVXMt2^U57wmD5}@X1U>&QO#B&jZ0J18_+exP+Z@5Me9xd0Jbq&L^e7(>X zNNZ(5fx4(0i?cEE=!j+2!b@EfJXIo&j};GwfS*019h#N=Yt|*|0J4`!D5 zN_q7;3^d-)FNmK&7&H^rwGK+yh}q{Hpt?|PFC?Fm#mlG5xknmlrQ>IgB05c3KF~=a zh6K*nAvP~CiOXlXY$wlxYQ8_)WN;>NeiQS5Mb-&Nuox?GER-8$-`li(QhmzUy}Keq zW@+_RPM`C|bx|r{2{VLpv4kQKehI>QOprT%3zknCxVb_F`5u!3W#trOn>06Z6D*XH z=M)M2!jWK4RGLfuttE%E2P@F6hVZljI&jmjn43^ zPJ~{D)br75_H1XB8(ej-Emk3-$#Qk8x9>hEB<9vjxJQ=EG&)&*v=3TD&pvVnxeR-) z?Lb+YlOky39f%jYERz8;%h7@zQH?O%8>!r^nUZ(>IPqq+lbCHA8Ax24#IZ@dwzGe_ zNr{+ocSoD-L2*Xdg%@t^OiJbgq#@1W&4(>T_SLJKpM5HrJSQaRRfbG&uyI9+T~>My zyWR{C12~~%bhg$$vJk%xRx<*^v~v)B^3%hV33i~-tUvA5Sfb|5i=rmc9n>)2!GqKa z^P&<_F>DtK$|77CJ5xuKX-Q%!OtxP3n%EsDQrn82M%6F*?l55XtzSVcMPQG0ZuQjl zmq*Ic&aackwk$S6PqbQ!TT;VJDSX~x&h0RoXfrD8&a{@qUZfVn6$ilU9V(GVzCpk^ zP$Zf;Ui%dnVGK2;ueF6kZ zFhW{mY7j^Tftei%owFtP`AO&4M?tOT( z;Htw$hS6rDA9#f<0l{2DA~U)NOfScqg!^m^q#5Caibizsnh)JfGIIAiSiC=S%J|_X-AWeS|ich7A5v3!>zaS0qG@+}6 zF+61ADkXR}zFbZ1mX?PdOp=@C9DI^|;2Tz^0qedK3>_4z?WYMY85qL(rt=Zq14q`G zmX)L~hGa0K_F1zeK5O`YjYkt&x-#C=rX%}-v%xC}Z95zssU#Mk{YR8Je z@U4Wha=tl!xo6aPg=VsfWT-Uw*s!bATd!Jrcam6JES#?b>09?3j3HtW9zjdZo{@vm z;Qsw!K~TU*LK!uvRJbS;OkNH2Wt%Y^x3I4&v!zodO!!r6#`%hm7yl~tBXG|sE%(t= zztYj^vC$ivB^+7S$l7s@do8-L_omu&g;hi4Q7^#p%DB);DAqKLC_yf{M--fbVCW4Q zpLSAJpyR=Jw|FpZ7!OY9&`o&H;FE5C-006%H7z?V^+c?EUl19l4m+%pxM%W-d$e~- zt(|&Ex@CFK^ihfbnmM|@OUuO+x=YOaa6Up`MZSv=z+ zj&v;Xfs>|(JoZyyf*n#2H&qEvkEBqz1th01TIY?cy1siJEZd%upf04|88q_e^UcqIJI$qO^tX{0Q=;ytn*d0;d>W zpbMg2hvsXQ_P18QOkwPq?4dM+V|(uRBPZ<<$bpw08v0vS$9$VUpbm=Fv(IMqMe~ij zM>0rOq>iZMoC}d%y?jB;97(AMLyv&6Zzi(5LIvB?<#Ywf0)mZ_~Rdangdl z&@8jcCHuwoEo63_;{rqY2HFx=n@YZylX9a} zl&P9Yv{)Lgc|b3Q1o2l|SANshLidoYfmF5?I`bsF`E$9kGP};}K?$qva#L^~CH` z!TFGfb4WF(Bq_ENC#V_OREgx>tR!Qa(Jg2?b%7g;M5AE-&>&(JHfZkcmN2s4eJeN!nCrcl9Way`gTk=o|nGo|BD1pGHLvB0ih$H-WM^@K##RBrgEQ`4$CSNzg z8QjInTy|bpvXE2PqeM9*$mGvZ!Ps7Fn?$@*V_0OIlsGq$7xq#m0A&oC)8WX5OB{I{& z&m4D92ULj=J&5P>4A>lRn(KPS@|aiq-&TfHnOC`uYpkgbZ!za!sgrKX&HmC&DR$Qw znLUwmqe#(ab!;OBsne)NG--Cm>qV#<+25uf(vCyt?AGIMoJse#4t}n3bFn42(girok)X zsLlF0m3f3uPV@^VjN3J zs7vW$dREOUH=t;vnxK-_6qp*ejG&zM*m*>v9wu&xniWe@+eJ-67VZtoVET-b0X5{6 zr(c*Y=7z@KB`=B#zMR8)M_(&sn@t?LtNkyD`lrk0nJapT+`Ued`PVEyOY{v7f2Alh zxP{mY>C3kmqt~@Sx9=weAH3PUD&9e;-4Z?DM%u2JrA~7?nOo3Fg!@?ilHRb~Q9Vh0 zS~k)vttP$Xy9A>{?$-j{oKIM^!~^qOk9nFfO9U;uX<{Z}MGPU&T0}pPw4d7EHF*^c z(1Qo888T#p5hW(|Q-(yg#r6vVzhg0gpd>56bb9oH0wu}%3M)p2fxFLEy>QG4R_-h8 zU+Al?!eBv?3%sHzLA?4>j0E@%7$S|RYf_S$ylY+ z4n%*ot_mG#p83HvVERPUjJRH!Ay-9T%yQe2biJr+b%|?XeE(`??bZyWEqp{h5`F<$ z|26&q>X&o$0crC>TI-zNN~}*w7-kFnefLs z2fQs{{%-wM-9ryBgJ*Iuv&{5yuKy+Eoc^si>??Jju|gyAn_Uf`ajXB1%g`EBtwiQ1 zx^awk%lc*V?-yf2mx&<2oHk?3d{TaxpMu&Sc>d+t2h>+*DNg;iw%P+Pbq56MHt1{8 zuC!j;1YlpBL2hXi-rks7|L=db0Mz7?nWiEF08stMZRP$Sn6!kAqm#as74d%`|J5u1 zZ`hY{-1iNNl z1=2bj@r1^~3~TeQTAAId%fY2ha|!FRU6VMpiAkkk@VViqVwhBxz8SBI0v70InyyD6 z3Bn|Jj3nVomoatTh{xa7jx;yvi_UnW_#l*M<|9E)rOc4j#iVycL>cKHTtp3#k-nKL z+7?|mS#aSINetxl?nE8)%Zyk>!C1k`<{`huyPwZD2`YbK4!99|Okznl56^r1}88nU&cpyn*~f zRP2FGaX0@#FpvKuii!WfqnQ6~#DBA2l_uoxjK6W&?wmdns)%IKg2?m;9KE4d3H+J4 z{P-@21_oU4WQ76zv4`7rf2c8VBqkLlTWX8sn;VP7*r9$|Zvr<123Vyh&st-dNnOt) zxtL4AjW-w3bde9fPrdt&)f0toUJ2&UdD?Duy5Ap7dEF=0V82i93p+Kxkri{*^!QAa z`)V#?MO?Egc}EaN?0rV`N9>*U}noU~6E-WouZiR;Mgh z;i}OVBurvrDpRj7!i%ICbMj)VT&(w5JB7dEWs8$MSfbZaa1D^jw$rlh41JSI!*+g5 zc`HjldKt~dEdKiq-t`OW#SHiFi#h4kU3|pR`S;CF5SvpIp|Cl8#>|qEO zL6o_yj`uN0$wSqXQfj)_qWIKrnS3$j-u8y`GrF8k5xy*m3E_xC>4xG+3@28lsi2dl zG->G?bNPxG)$u+RlKOK*4722EnDvKFTfCP}MVn#i1AP7T_HVVXeMTs4JO zpT_!OPG@)cEQ+es9a7Q~8ZJxuwg`RN6PqI_ZGrR{=g#vc28nWQy+I8dcb5dFR^-u; z&&P%sTVJJ;F`R;9s*$hDbF31St>mkHWdp=P*}5fF!x?lQhPw$TMi}e=#xDm^PWJok zBklIX+F!cN8)z!@No~Er@9ywmEwj?-&7I}xh?Aw0SPtK(3EQ+5LHqwwu+}k1p;#vH zrvh`dw3QgL-4@kIQ!Av--?{@#~s8|+dQ;(;Mo#ndpY6spn{3TJBv8{Ee0%vgX2)N zCCV1=Y(p9TH+hpYR^mG9QF6nF>tHb9wDPpXRlL7F+QvVV*IK(W=+D|wiR-*I;elS7 zY`O=x^{a5b-2CDtug6c%+y!Jb>;Y$1|5k+KbP-$ndnLz+PK~0IJ6_kenCmP!NG!nT z0oX@l4sD#DBU$@kjnc{sh4baeOf!mqY{x0?+@X-P%tFTkGt+fK8Xnl}SW!g#bX7&^ z+2;eo?q}&im*rirs}E*eubvzp8ZZ##(eDL0O^$sfaX!0;rmj^d#vG<0v5$vbadqkM z;c@S>jXq)Rz%lvuo_XtEk0U!0-X%0LG%_Oo&y;sC!y!Vzbv!1e%gjo7+E(!P5CXQg zglw~&%zv|GAITU4^EUXYL*ba5L|+fG{n2f#<$P`;XXQzw!rFG>1xIQtjYXPCx$0Tg z_y1H9*k8*NMu;cG(T9I5k|_z+!6-KvLctWLG?awCF`Wto6>5{_B*kX_J!#TlRfW|Q zTxT2;H#0}=YR;55U1N;$dTp5H%;k}GCmbbyfA00QK5!SnK;wWT_=y7G3YX(F_2ej zekKG-;-FFYlnsInfBS-ue-l(=JyzlnCV;dv+bFa!pd>$1xZyr37BgGGzr|0+^O~0j z15^}t&e-E6dU|#)QNVmuka5beLq1^$=n5hx6Mg@fLV!rjf(f07zjUyE!{MRr^$O81 z9c&-SdtEZ{pn(T}h6ZnUS7wPMBn?d!5HMe!BHRBbb05=@24O?2h_`+1 zSkky=Y6p<;hK&MFs_UV3Pi4-ZFlQ5qOdAaJ4>=1O04Q<~*!bCF?FPS~o{er4?b z@BAktYAQF=_~SF#TF%vAsN~HdgBetV+7Sn}tl<@KS7SOg0f&fC(;da%oL1YWSL+*m zGM#5P_te#*^#`lcd2E#Bzrd<*Ozyihcs6GM{UIN@;iOnS-MRs~qr?3IfIIow<-ibm z1axfeXk3WdOtrvL9~RrkL@RPE27Wm{vO5xg=Y{Si6xRMyB}nHWVL(7VUs(tiyCf+=eFX z^v*e{k1Tj6MkZdZ0LiaYY^zFpCUo+Dxx=bBlNeU*IS#VeeOAzI)Vt^$zh$j^EZMHM z**h+Kz~xZ6N@mz-#ETTbxO`K|Nr-N;@=2jQ#7ZgkFx(W;GWygjB|Jx@jU+qS`t!IrL_@Mh#X_TZx%@ z^4p_*L+-*ol_Bw(5gpCY^}j0qLkVl4eKqJivQEuSwK~_wQU=a?(Pr}B&EB% zySux)K|s1&x?55}O1is2>5>k~O$h(?yyyFj*W>Z~9|mI&_Fz2Mnsd!nbFSyU4NmP* zk_r34gxePNOJ$h6cykvyCw$qW0>}3|r&9U*AFcQWu@^Z90;YM#zVCO^+rx zNH@pXoqevqr|SqP@$wvXr8J@&d_JP>=uXmMSW8G@sN0shx}NXhJ^U;k3^P3*Y9*{X zT_){Q>`WUL%w79gi?=u4Dq=QB^rnC>Qexc!1mCKET58qi_4>ylhJterN@VVP&{9R} zf`VGjgzL=<92XlYXsi4V{!C1%tpasaKFas6LJV)K-=vfm;P_v(pq!FX4Y?&YsVKhO zR%%faHzRDbQ!M3E;64T2WnRzcuczPxKYjJ4E?oK+r6|}!&xa}zY4)CB2A?|sZ9Z0a z|7}5bo3I!eu5axh5J}j*49lzaa_Zc8rw3g>pdb(cSDK@($H8DyJ~4-_*`cwZ$s? ze5h6-?o%Yb`5-tXa|0?FF6Y2tk6?PhbB~VSfa6cTW01)6;9^4dE+jka44m<(+qOx| zS7+%A4{cV1vYAlL_6DE@7TAVxXLfPEJy)0APHnPc=nL6sYxCkc(#=FY#J=VU)@bgA z0_~_L;7&Dz1PtGWxfn&<4}Ma94p>_udw=f*7k4kv58VQ0lC!J^kehlmGtWV4Mi6UiYHz1L*lE`k@;g5_yK$-= zZtu<-NFGqxlm4JpB#T7g%Ex-iNmQO!&y7g$cHfwbO|=&7md}4l4Mn9|n24rEQ^>Ux zYO+gTedMAD(2~_1Q6k*FOpy38A*yn7gLcbXj?+s+U;2tl$BG4xn$@hHmfNzSfuA*V zDR8OI{FbT?yi6r34Q}@hSTAGKo2ggB19-#DmV2x|Zadz2|rHCQV8f=qYq3S-XQKr)V!L{fbjC(JB{i1oZ ziF#JsGKmxT>@0|5a3}*}b2#dWUIr!i`8n>4;r7E*)&qvB!SvEbZkC%_T$i>HF_iTK znSw(apn9nYdcK)KaXd!E__$?es}T}>(H*ztldjGo3~FxJOQHIwDEbA;V7L2u0y+iR zI z`Ta|+1SVzj1fro-ACvhOxw!`lkeVnt+5zUv+2Q>l6W3DEHS!?GkLeUc=jF=*DYi;4 zgAmXvqwtL98S&@oBP*(OL2;6Q!{jJ!x!SIzc(UKP=n25KVnzea3MJKb=3u8Cm>iLlc zo>?@$-95+WQf~)EAZt_5R=Kx&-+eesXf5(h%iWVsgV-k<5sR4Bt?SzA!_Si!Vs17{ z{6tvfF)5Sptk|88Zta~Yi^wNgFB3D>72<4rA$j}O^elvaJgTjo4ShF~YmiNpHeGbr zyKXGp)-!&Ibd!z^zbI+4QbF?)fGbwcwDyLFza9Z}=ghoEC1>_-5DRf*_-4`0`D_3% z-j$9^NUELnMfu|?&hgFGHu3n@;Oi!chfyGFC1tj zysM2L<;pVB&eZILeivP-DG6^E!_0P@Pv$*0)yMcNP8S ztipdgy#t~iDVyOeruzZb?;xzt0NZ53utk9^3ZvN}(iFQco`XI5+!2~Bt*g7s$UI9V zqTk}E=N|5KTZK~u!6+3ngR++0rc2UcL~b2^1ySOpH^5EkBa;19dk^IoLT_D(^eYV? zh)u!~KjQmm97L8GO!T6q$6zM-+4)P@I(QCal||#8B$YWzh+EnD6~{;lGD;KM(2Z~x zbfm^>#(c>3<`9QS(Mb$0_NoT37Om8`p*ft5u4+)-eY&scXqIdG8ph(=r%k3w~PVLOXd zvY%SJgzTUS)}20bSmIE#Ku2ArE#^+hFkz~5s)Jq}y~;DcyBxahE*PlD`+}A(u^rn<&8zczVDn%^A5dk-Vy_mr0qL*uM z+kH(G>dhnCDc>o`r?(AIs+^*rfe)ECTkV3CYD3Q#19fXQhe<>BD4P`WFJ{4fglrGp zMC#o(hLNzR_6BG%EOWFS0kBYlhLR^aX`ly0}L;y&ATq9Kgir+g(JSTR7eC^Kd70rtk@Qwh@u3M8?jc zvgkQ+ER2q@6iY?Es?2yUOPXy52HHmmw09OlCy8i1JSX$cFQ?Kz?WxLaD*;xXXdOZ= zBkjariS2=U=4{ztOD4WdLby%7@-N=%81G7r_onmAC}*~wh&dH`ElcXAaT1YCg!*3c zydPyIQxoLY1}B)t!AYV-sVm|=v@yqXQI~?W4Le?d1`+uZEGOQ|ee*VGf zrT|&74wW?}lFB{`V02N9RseY6=RHwR+vczuOFPU6KW$IutXl`cwNkIGa12qG zrJ%bP3TNk7J?}yS3x6XEWxoN1EKl;n-Jr)OR82@8A-lLcqJ0m!DhivFnJu)P!CIZozRj3Dupfu>UuxP6njtRWN0x(t)#GPjJ(W*QX;@KZebajIc;dm zCW~hL0jRsrD=aVq-P|3Oy{?-lW2lzd!ihrjVFr)oLbOS5oQOiE*S-!;?Lbx&bB@wB zIBCNkoH#5Y8I#5PlHx>EpLUEIfBnTV;pU3R%nfkZ z!YFhE-!>M@7lKEDX})s?nHWmd;*DDNM6GEm7PaY{ePtQ7vU*E6^Yo7t_xmKXg?pIw zLetbL($kGYR?TwDFJ{6?y@??DP->A;k*WI-u5h`r_Fj=a1?c8CaYv_fx+w3Y&sz)# z5l!Eerg8T>?FtY$ym)%@xf}a@V)bx@rCghzp-=;#(K|s@NOO*IZA)NzB23n8Oyp`N z6Y_)!pjq5GpOl;|9mspLVAjuk4Swf>dB>Z+oWGfksTiJHt6LL8{)`TN&}5mlo&S@f zn?k$j;4E88b8ms}U06xznINvR%znonws$*X0nXu~KR;D&0=; zq1MxLBj~1VFmZ3_rpJ&0B|edG0LL4z$TA%JtOE-~IHfCXompV+wy z8-&6rt-RaR;6BG2HZ5IoYkQ!W1K80!*5H1C5|T&@US7!VmLWU9nG%2IR0sf%g(q;p zir%R2#OCiM-FRbfu?u|_l)-Q7I{}F_K#B)nXF9wXSLm-9xO`&}clEL58GaMK6`1Uo zQKob~3zs=o{h-kD;27bhfCkdw{8=X?mD$rB(iIfJLV2z}Inma$btemM>{3VY_dH`c zRmH*W_;0{4Bi*0y!=kq3gCg}!KzsqQv(?<&2%Y|52_E_JZZE7axCF6;pWKz-h9;(1 zFEg|lBDp{TkLtU9pc8X{8!)$h;lT}wYiX`cFvH{sCC$IJ1nrkGsX1R-c54t zLc9jBHVaK(PZqQAK)*w|rQxaCi@4yDsR;BKp_0+QMY4^V@oQdty=y?g5jigp7$EqZ zjDUR~x@7qfAlguTFi<0JZx{E(?05$3ZrE!(`+7JwC(6-O)0zPfL-;9#k~GMZLtGy?nM#)>2+T`kNj ze-Cd%!Vd{3rx0cOIo+1L-plN7F!@)*0?vWum?{xsvwILKF<=UycOWzqNrt^1DAHo{ z&>l4+Ab^}}aY{#leq4;cq6#<-V$Ho7UKVZ81@Wh+CFOY)SxBEZUOMd5^n&4mJBI5y zhiL&%RP$EK=dU%dsx>v_%dKWSAnH{~OU>To6_twC8@+RTFwOV zjN#5sZh{G`WWFrn$+vV8xa_EdxGegTh$iG5fdf8|IkR2eF_u{^F!2%tv7EYty{ytY zfTzxF4)ngPoP_WTG|Fer08u&Q$%>o}_7yWw_VUke{^I-nDIPLL`#{~ep5)0hW*8ez z$=vvIc7ys0bTt^Z4cC$pSAr8jP+)*}S0n5;J4~41b{%cIM*fv_$1_a{7~CzEGF*%a zmo!~DyV(mH=a!>N6aTXY|l>8fd_G+w#(nF|q5jcLBA z13?#dl>PPCA}RNzqD6oVO(@OKym{I-Pa5JmLRwqW$FBiUBnL+P2)@~J(ec|s_sm!R2@$OKicGYN*2GqU(J&T z{Lqn)*=vxuAX1Gv0Dk!C`pCTtlDrGq_gKcHI?^jian>rS^UL?G0{-ilaNK#DTyw56 z{Mo5FbQ?Hew~5Kllovle5o!-n7?EA%~9 z%jQnBip8H@%a9KGo;gZW59-6s%P>_Y62@fk&z9tt_3vec<8wZNl}y-DPVJOG|Iin_ z626Fx(_8z21@R?Y6h3=m$wyZ(m0~u^gGm$C_>_E9bIWd}w}}Fi6`vO0&SEgSdVWB! z70oGSTwI5)%Dq)n3w0Upp_=|g;_;3OZw=}>WJUsdX*M=A4EsAwYD>0ZPrKc^Y`%(P zR4QJgyJNu4aNup&3279U6_ zdbsfLmw#jb+-(ai0SJf=$M4ESh--^XS307Zgwt`pJ8{}aNm%u@LRcdGx zw~H)F7#NIpX{7#kW5V(1H5 zz5AdL#5;!Xs~elu2h{fX{pR6_V=3+&^ruJ{iTx$`s^O_)RYD@?{ol+}(o43PDCFcy z>6@z&ig(9lnQ&Je#^YG*qG0nV5izc-nDi1Oya!vptC5L&xq!LbWas62!Jk9@Hgg$u zcf|NzytpAfC_?Eo)ZG&ywyD+)KyrtAk@F|5=o#Mda4t2W8yW1la)U@5zE9jn2t8L( zX81%5B2%>F4iIQQ*!=|^;t?PSN?@8gFwrSJ@S3$#y8xt&xUbuD-u=7}9#eLWR72-qTT@xu+BTcA6}iClYMq3D|3PS&w~_olnHK zbbUG}X3XIIUV2VpcbYSqR^lWK`E;G4pb|N_JYdhO-P9g;3Pq zx#XGZHE!5Xc?m~}&3$AbIXJZLI=xQV><&VT5CXbQ&*Kz10ue(bo$2A61QOcN*>`p;EOKRNXLPtn*{8w3F-Cleb(>;Dq;Q;C(4 zd?J7xq=(1C&}V+H(IjuWE!QWIPhSF^7YZk!fUfOIo+QzqwU^5k7P>3Y8U%-;?GA!O zHYcntF5ohIP^By2K2uO|W-gA~czK@O*61M(U{K*rXX`j+=FR!L5*bC z8%ZNoC}V;XL!Kpb>sP)JkSj_sf;rwMx2$<+g%bK77T7~8tSw-VD@GV=JA)2g5Hs@& zN(X^2sMAj;J;5fpbBvQ$s%Wr@mKo`t|+60qbQv%_fRc(1N8*2fDS zc~Y)?i3pyo`Y`?2GK=TmHMB1Sk?@)-KhzR}Oj=qWo(Ut-uUx}_lC%xNatZzBfmEBJ zSB2ILfPtS-VxP5RivoeD?|F1}MKFC}S2DXwe+>&i*)@^(pNc<0Ylm@t;ENoizkQkG z#jnpbKyf#qNVcsT*VPwT{GWW9AfDFmg(z^eN2;&JR3~wRYIg?8~`b z6w+Q}ETeZ#j>1Z?z5425VK$AnXI=J;)o?YW1AC@*n=7rc0xy8rmLo~Jcb!bgn3ceG zv1@S2g~rpP*}ia;hD~CRV%Kn2XA_Ux$o_4-22CZ*sM5r!eGy6Peeyw==5WHgAUBr! zfvRYibkq^Pj~pB0`BIi)Xx#xu3H)+%OM`sS+HY@3+2tFUh{#~*CgyA#2A6>lqfn z6S5O{6{Wk3D3`MS+HG^VfwulGBaN;h`#huNIg<4%zjQE;0edb^GBt_26eM9Eg~2<= z%x&8wNd;sz2J(b`T`Vn+b%GZu!pg_&@u44I_b|jc_M^Ast*GX% z~cER`C{E`DzN*%y4r>@ti4A$Le2~6EEK|BE&%nFopIQQ zN!-D9pX<=ija}?3M}Wur)SnR4!Q^=N{TZI>K-5OX+PuZ@ecEdP)O|3 z;Z49IgbEtgSJg(*(Aa^$Aoi=5ZV6^_E4HzP)mn?bbRzqSk-Q@}P! zU^@l7uS{R0FQ1#*uh%#!jP+VDBI7|deK+xz-o;cMwsFQa_N6oU`m|HL^uTLD=QXI? zqFiDND9*>fT!W9Zuh{5;R})jH-(6Au;dQ~kD`bIM)20??E{+DjC_(m7K9a=~L+3%m zmtNX7LSUw(wb78YdD4gQYKDwb0w6BK=Xyc%RRPAvWSvJs>w0h2R385!%w)PxhWr&M01bMie zx>a1ez2u_4;Q$qR#^a%(z`bD;W}PcbW;gZp$;XJ(jj16;20aY3xp5(V_)^EWM`}Gr zK#ADYB0DVWY&9JP_oH)FDL~K(Y0HNT%jo5+7MAC6`q*B*BqP)IfOA zSs1}p4ht#5?g87B?XYTl`HxLvWh($kg4e|Fz2Zvohr;hXR?n)(=s&V%ugp%$J_YTVFooJk<#&j9b704}aM+b!QM* zY2B{6NUDF@2GpzM?B-{6Ghg#rk|qw*Qr=FO%CA^HN`cxwni?*?^I8;o%^2I|#b!@H z!~kFZVrVLm*xR}zG$0!nJB)j{!+gufR3EieNl0$mvb9e%%PXc-huMH^XTw*p?1 zYyBDhW(uaF%N2hMyCTWakzvUi@hY_+R8p{u`b*vcrP^U z_*g|+yWK|d2olI`sQ^ThBwo*25*7;P@yH3tB(f9HU$-isz0RnuWHIEzUyNIb?n@Re zv$Du(b|ul3b3Fq0U>?6%DxBrqHZ@M!(Q9Sr<$XXSD&RZR=lmi8#WaVOpR03FJ!gJX7}xq)vi!L65L~h`COI7w7PQN!xMG^TmKZsOTAK%u z#7EYSymBa>Y&`4@Ffm&lxog|JGhG>BPx$u;Ig zhanra)@5TBV{@8(le)od=MZScTHK2=8cikHIuNW>^0PQLiQ-@U95r?P0sc?spnX8XB-Fwp8ZN9nk*gQNY==j2)0kCP> zDS3wH9LV%ani_3bU2|xy#zAU$rwL<`uAe~6y>{(&G8kQVUiZh>m`rur~bZ0XVL~QQ(q<_ClM)5o8+`+95hA?X0lOj&2f6?i%}xEm~y3R zZA1w3h^*;MJ*GFdRrP9o(a}EeSy$0MRB1H>ND#EI?o(ILX|D1yXsML7Jz;PiQelZ+ zp!i9t0BZQ}Y0c!zH|4A21GdDR7i)Cpg{XY}^=@lm1vWb9>y^p4F^Fj{5|XH~U(`1y zf0U&kUb4c0uQ(#`!MNRwE;%*DP}`saRhM}Q@8)WSInEKkDq_N)ih@A^4cDIuzpTR1 zg1^TRqQx;vVRq~}7XnA(a3&`_p-X}Rp+M!R82&a9yRuU2)qbcH!*(OuBG-ZxL$7^3 zk&b$I^~5I@OdQRRR`nvwa|Z8Ax*#R#RSH|9#$u7?>1oDhG*RHFDlwSr4bi&61QLwz zDLzl|vh{cbR+{+2Riced&uLkYy9`dK_ScE8u`N&ueqg2cUruA%=)P)#35CF58vwV> zIFPBlmMmvWShXzwjAC;X9Q9dnE`&F@@U8Utn=nx1ySEfLX(0((;LiiMhO*{o z332vyIVs;A+_1A?y(oW|?Fl2oUa(^_iON_+oYqiYgd}-iq2eyFl8e*2C7b|Q$7#)w zm1s2=sH^Fdv2u>d+BWU{?4KqFr-5CP>KbEH1xpYDVVij6M-c8AG=ym^@?d!I(P`9u z(W@77VDq{wy0<#R`)C@Tr;x*YPD61$^u=U&KnFrtLk+}c7XYQ}!}&%5t49-o8#I6j z8$BWc@|_PmISg)MZFq}`=(Tu&Y0*gn=!zUT%R6}HnzGC1I3zr#o#GHqMQG@>OzQj7okNAF z(psjhjkl6sE-6TI^GhnVg0K&Qnd~;28l$D{!$=pSZL9m)_hz5f__8{k;McQxsl7yL zoV4+ZL@DetHhsB+u&|Sr*#=j%+t!eitu!F$RMK>tLL_&GeKR_!oe^eQ=FnS3U9fs4 zI?FrCXlH>RT``+eW}G!(+Yec7JR&Y?WJi( zmoa%r*|6?kWI2MyMWFR&UR94W?=gsTJxJ}_*g_YkdUWL!owBrj-lX=Hx;)8+BIbFr zftcCqOWQ7{96mH7cGBrD==xgg7+$j^gyKT_a)O9QZ?{T>TX!jrkd>J#Cm|;2;tO2| z=43{SY5NJhTQKQ*&oeNy$u#WO!de&b$r+usOzH|f+vA&o_9PCcYXVad((7s>b=O!Z zxvTY)LL%1i&SDV@+C7(o`!I)3_ln}{m?q?=Y~@fKh>zj!lY5>N_O3$Ml2U5KPx+(7 zN0LYrf4JaN?NRvbXSVht{+PCc8`(XyfG??_f2D8e;jKH>`WI|T!;WbjqP9zrm*ZR7KW`bM%aMZ4>;lijsSslVlc+pT}&WfxFuQSMv0}uM1%mqJA$7GWa z6pIIode$f6LrBHlm1tMmunGE`=P4W`HIGYvT#t8kYINF0AA{{c=jGrCMA7YO`<&7m znPRW=3T+R(iyAEZD5LAgt+0a^)JQ95Y} zArV<65fxQBr;(Bl?f2HlYs0 ziGdJ%;O|#epl^W;RG_kRG@~>7OHhi=$l8MLJ1b@ZM>7{2pdviba?Qm47dPlXx4gn5 zAS((u#k2^#&-gl#^es}6f5-WyC+g41pS(8g(F7)M06uYiwe0*BfoQ)={+9!*<1+zM zpe4zFKtG#={Y zWh|VWfPQ@cp#n$BpCHi$Fq3A1NJ*f0`j5@bc=iX#zgcbujwXNJ%$8|Sv|Ql8_W^R* zf9Tq6-~sy2ga7Yw^MCDC&}KYbVj#*CIDmc}rk9j|j8g*IG1;2^%l?~tkPAUa! zoPSK-#rj{#|LUpVSk(V~Fn@15{M8crTjX;6d-DGbxPRIH@BK7?9A#=eKOijruWrUa zH|Ben#;-;|-(p?xH>CfwTj$T*@7>LQyk=br|G@pFquD<@LjKJ8-uCLNSK7B=k^Fbg zA3CS~4E^4B>8qpGw|FJ}1N48^U;fBn>u1XM)-XTrI(OM$QvTNt=KtpC^fUK+i;S=aQLge^)V~~G-zzMBoxuDS=O(|*`v;1gKX3c@GJ`*k za60qfF#ev4`Df+EpE=)Gb$=Bt{1(v`f5!Qj&icO6_{Yu)@%|;?4@$*8G>EADkeqE{m7WL`BO#91q`=2-V`_;N1uP(+}zs&l(<<*~)e?RN~b;0jj z5a;|l`5!F*{S5hjw(!SY+EDOI$ls&#chmVlGroU@`a19UEsRQj$M}a?NO>s;-~$;5 R2np~f1o-$>Q}y+){|A@R9n$~+ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index a9db115..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,9 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 249efbb..0000000 --- a/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# gradlew start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh gradlew -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 0edfadf..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,82 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -exit /b 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -exit /b 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/jitpack.yml b/jitpack.yml deleted file mode 100644 index 2e29797..0000000 --- a/jitpack.yml +++ /dev/null @@ -1,5 +0,0 @@ -jdk: - - openjdk17 -before_install: - - sdk install java 17.0.13-tem - - sdk use java 17.0.13-tem diff --git a/lefthook.yml b/lefthook.yml deleted file mode 100644 index a36e11b..0000000 --- a/lefthook.yml +++ /dev/null @@ -1,10 +0,0 @@ -pre-commit: - commands: - spotless-check: - glob: "*.{kt,kts,java,sql,toml,md,yaml,yml}" - run: ./gradlew spotlessCheck --no-daemon -q - -pre-push: - commands: - build: - run: ./gradlew build --no-daemon diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index dec4196..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,65 +0,0 @@ -site_name: Ògiri Security -site_description: Token-based authentication for Spring Boot with pluggable sub-tokens. -site_url: https://quantipixels.github.io/ogiri -repo_url: https://github.com/quantipixels/ogiri -repo_name: quantipixels/ogiri -docs_dir: docs - -theme: - name: material - features: - - navigation.instant - - navigation.top - - navigation.sections - - navigation.tabs - - navigation.indexes - - navigation.footer - - toc.integrate - - search.suggest - - search.highlight - - content.code.copy - - content.code.annotate - - content.tabs.link - -markdown_extensions: - - admonition - - pymdownx.details - - pymdownx.superfences - - pymdownx.tabbed: - alternate_style: true - -extra: - ogiri_version: 2.0.0 - version: - provider: mike - default: latest - alias: true - social: - - icon: fontawesome/brands/github - link: https://github.com/quantipixels/ogiri - -plugins: - - search: - lang: en - separator: '[\s\-_]+' - - macros - - minify: - minify_html: true - - git-revision-date-localized: - enable_creation_date: true - - redirects: - redirect_maps: {} - -nav: - - Home: index.md - - Quickstart: quickstart.md - - Guides: - - Configuration: configuration.md - - Database Integration: database.md - - Sub-tokens: sub-tokens.md - - Authentication Flow: authentication.md - - React Integration: react-integration.md - - Contributing: - - Development Guide: development.md - - Security Policy: security.md - - Changelog: changelog.md diff --git a/ogiri-bom/build.gradle.kts b/ogiri-bom/build.gradle.kts deleted file mode 100644 index c4c3b33..0000000 --- a/ogiri-bom/build.gradle.kts +++ /dev/null @@ -1,74 +0,0 @@ -plugins { - `java-platform` - `maven-publish` - signing -} - -group = "com.quantipixels.ogiri" - -javaPlatform { allowDependencies() } - -dependencies { - constraints { - api(project(":ogiri-session-core")) - api(project(":ogiri-core")) - api(project(":ogiri-jpa")) - api(project(":ogiri-jdbc")) - api(project(":ogiri-caffeine")) - api(project(":ogiri-redis")) - api(project(":ogiri-test")) - } -} - -publishing { - publications { - create("mavenJava") { - from(components["javaPlatform"]) - pom { - name.set("ogiri-bom") - description.set("Dependency alignment platform for all supported Ogiri modules.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val key = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val password = (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - if (key != null && password != null) { - useInMemoryPgpKeys(key, password) - sign(publishing.publications["mavenJava"]) - } -} diff --git a/ogiri-caffeine/build.gradle.kts b/ogiri-caffeine/build.gradle.kts deleted file mode 100644 index 7f524d5..0000000 --- a/ogiri-caffeine/build.gradle.kts +++ /dev/null @@ -1,146 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - kotlin("plugin.spring") - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - `maven-publish` - signing - jacoco -} - -group = "com.quantipixels.ogiri" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } - jvmToolchain(17) -} - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -dependencies { - api(project(":ogiri-core")) - implementation("com.github.ben-manes.caffeine:caffeine:${libs.versions.caffeine.get()}") - implementation("org.springframework.boot:spring-boot-autoconfigure") - - testImplementation(testFixtures(project(":ogiri-core"))) - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required = true - csv.required = false - html.required = true - } -} - -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { rule { limit { minimum = "0.50".toBigDecimal() } } } -} - -tasks.named("check") { dependsOn(tasks.jacocoTestCoverageVerification) } - -publishing { - publications { - create("mavenJava") { - artifactId = "ogiri-caffeine" - artifact(tasks.jar) - artifact(tasks.named("sourcesJar")) - artifact(tasks.named("javadocJar")) - - versionMapping { - usage("java-api") { fromResolutionOf("runtimeClasspath") } - usage("java-runtime") { fromResolutionResult() } - } - - pom { - name.set("ogiri-caffeine") - description.set("Caffeine-backed token lookup cache for the Ogiri security library.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - withXml { - val dependenciesNode = asNode().appendNode("dependencies") - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", project.group) - appendNode("artifactId", "ogiri-core") - appendNode("version", project.version) - appendNode("scope", "compile") - } - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", "com.github.ben-manes.caffeine") - appendNode("artifactId", "caffeine") - appendNode("version", libs.versions.caffeine.get()) - appendNode("scope", "compile") - } - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val signingKey = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val signingPassword = - (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - - if (signingKey != null && signingPassword != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - val pub = publishing.publications.findByName("mavenJava") - if (pub != null) sign(pub) - } -} diff --git a/ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCache.kt b/ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCache.kt deleted file mode 100644 index 293f158..0000000 --- a/ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCache.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.caffeine - -import com.github.benmanes.caffeine.cache.Cache -import com.github.benmanes.caffeine.cache.Caffeine -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.spi.OgiriCacheKey -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.tokens.OgiriToken -import java.util.concurrent.TimeUnit - -/** - * Caffeine-backed implementation of [OgiriTokenLookupCache]. - * - * Stores token entities in an in-process Caffeine cache, eliminating repeated DB reads for the same - * user/client within the configured TTL window. - * - * **Single-instance deployments only.** Each JVM holds its own cache — token revocations are not - * visible across nodes. Use `ogiri-redis` for multi-instance deployments. - * - * Activated by setting: - * ```yaml - * ogiri: - * lookup: - * type: caffeine - * ``` - */ -class CaffeineOgiriTokenLookupCache( - properties: OgiriConfigurationProperties, -) : OgiriTokenLookupCache { - - private val cache: Cache = - Caffeine.newBuilder() - .maximumSize(properties.lookup.maxSize) - .expireAfterWrite(properties.lookup.expiryMinutes, TimeUnit.MINUTES) - .build() - - override fun get(userId: Long, client: String): T? = - cache.getIfPresent(OgiriCacheKey.key(userId, client)) - - override fun put(userId: Long, client: String, token: T) = - cache.put(OgiriCacheKey.key(userId, client), token) - - override fun evict(userId: Long, client: String) = - cache.invalidate(OgiriCacheKey.key(userId, client)) - - override fun evictAll(userId: Long) { - val keys = cache.asMap().keys.filter { it.startsWith(OgiriCacheKey.prefix(userId)) } - cache.invalidateAll(keys) - } -} diff --git a/ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfiguration.kt b/ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfiguration.kt deleted file mode 100644 index 400c9f6..0000000 --- a/ogiri-caffeine/src/main/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfiguration.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.caffeine - -import com.github.benmanes.caffeine.cache.Cache -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.config.OgiriLookupTypeCondition -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.tokens.OgiriToken -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Conditional - -/** - * Autoconfiguration for the Caffeine-backed [OgiriTokenLookupCache]. - * - * Activates only when **all** of the following are true: - * - `com.github.benmanes.caffeine.cache.Cache` is on the classpath (`ogiri-caffeine` dependency - * added) - * - `ogiri.lookup.type=caffeine` is set in `application.yml` (case-insensitive, explicit opt-in) - * - No `OgiriTokenLookupCache` bean is already registered (custom bean wins) - */ -@AutoConfiguration -@ConditionalOnClass(Cache::class) -@ConditionalOnMissingBean(OgiriTokenLookupCache::class) -@Conditional(OgiriCaffeineAutoConfiguration.OnCaffeineType::class) -class OgiriCaffeineAutoConfiguration { - - @Bean - fun ogiriCaffeineTokenLookupCache( - properties: OgiriConfigurationProperties - ): OgiriTokenLookupCache = CaffeineOgiriTokenLookupCache(properties) - - internal class OnCaffeineType : OgiriLookupTypeCondition("caffeine") -} diff --git a/ogiri-caffeine/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ogiri-caffeine/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 4995499..0000000 --- a/ogiri-caffeine/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -com.quantipixels.ogiri.security.caffeine.OgiriCaffeineAutoConfiguration diff --git a/ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCacheTest.kt b/ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCacheTest.kt deleted file mode 100644 index 59f2a57..0000000 --- a/ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/CaffeineOgiriTokenLookupCacheTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.caffeine - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.tokens.OgiriToken -import java.time.Instant -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test - -class CaffeineOgiriTokenLookupCacheTest { - - private lateinit var cache: CaffeineOgiriTokenLookupCache - - /** Minimal OgiriToken implementation for cache unit tests. */ - data class StubToken( - override var id: Long = 1L, - override var userId: Long = 1L, - override var client: String = "client", - override var token: String = "hash", - override var tokenType: String = "APP", - override var expiryAt: Instant = Instant.now().plusSeconds(3600), - override var createdAt: Instant = Instant.now(), - override var updatedAt: Instant = Instant.now(), - override var tokenUpdatedAt: Instant = Instant.now(), - override var tokenSubtype: String? = null, - override var lastToken: String? = null, - override var previousToken: String? = null, - override var lastUsedAt: Instant? = null, - override var plainToken: String? = null, - ) : OgiriToken - - private fun defaultProperties() = - OgiriConfigurationProperties().apply { - lookup.apply { - maxSize = 1000 - expiryMinutes = 5 - } - } - - @BeforeEach - fun setup() { - cache = CaffeineOgiriTokenLookupCache(defaultProperties()) - } - - @Nested - inner class GetAndPut { - @Test - fun `get returns null on cache miss`() { - assertNull(cache.get(1L, "client-a")) - } - - @Test - fun `put then get returns the stored token`() { - val token = StubToken(userId = 1L, client = "client-a") - cache.put(1L, "client-a", token) - assertEquals(token, cache.get(1L, "client-a")) - } - - @Test - fun `put overwrites existing entry`() { - val first = StubToken(userId = 1L, client = "c", token = "hash1") - val second = StubToken(userId = 1L, client = "c", token = "hash2") - cache.put(1L, "c", first) - cache.put(1L, "c", second) - assertEquals(second, cache.get(1L, "c")) - } - - @Test - fun `different users do not share entries`() { - val tokenA = StubToken(userId = 1L, client = "shared") - cache.put(1L, "shared", tokenA) - assertNull(cache.get(2L, "shared")) - } - } - - @Nested - inner class Eviction { - @Test - fun `evict removes single entry`() { - cache.put(1L, "to-evict", StubToken()) - cache.evict(1L, "to-evict") - assertNull(cache.get(1L, "to-evict")) - } - - @Test - fun `evict does not affect other entries`() { - cache.put(1L, "keep", StubToken(client = "keep")) - cache.put(1L, "remove", StubToken(client = "remove")) - cache.evict(1L, "remove") - assertNotNull(cache.get(1L, "keep")) - assertNull(cache.get(1L, "remove")) - } - - @Test - fun `evictAll removes all entries for a user`() { - cache.put(1L, "c1", StubToken(client = "c1")) - cache.put(1L, "c2", StubToken(client = "c2")) - cache.put(2L, "c1", StubToken(userId = 2L, client = "c1")) - - cache.evictAll(1L) - - assertNull(cache.get(1L, "c1")) - assertNull(cache.get(1L, "c2")) - assertNotNull(cache.get(2L, "c1"), "Other user entries must not be evicted") - } - } -} diff --git a/ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfigurationTest.kt b/ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfigurationTest.kt deleted file mode 100644 index d80464c..0000000 --- a/ogiri-caffeine/src/test/kotlin/com/quantipixels/ogiri/security/caffeine/OgiriCaffeineAutoConfigurationTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.caffeine - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.tokens.OgiriToken -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.boot.autoconfigure.AutoConfigurations -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -class OgiriCaffeineAutoConfigurationTest { - - private val contextRunner = - ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OgiriCaffeineAutoConfiguration::class.java)) - .withUserConfiguration(TestConfig::class.java) - - @Configuration - @EnableConfigurationProperties(OgiriConfigurationProperties::class) - class TestConfig - - @Nested - inner class Activation { - @Test - fun `activates with lowercase caffeine`() { - contextRunner.withPropertyValues("ogiri.lookup.type=caffeine").run { context -> - assertTrue(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - - @Test - fun `activates with title case Caffeine`() { - contextRunner.withPropertyValues("ogiri.lookup.type=Caffeine").run { context -> - assertTrue(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - - @Test - fun `activates with uppercase CAFFEINE`() { - contextRunner.withPropertyValues("ogiri.lookup.type=CAFFEINE").run { context -> - assertTrue(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - - @Test - fun `activates with mixed whitespace`() { - contextRunner.withPropertyValues("ogiri.lookup.type= Caffeine ").run { context -> - assertTrue(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - } - - @Nested - inner class NonActivation { - @Test - fun `does not activate when type is absent`() { - contextRunner.run { context -> - assertFalse(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - - @Test - fun `does not activate when type is redis`() { - contextRunner.withPropertyValues("ogiri.lookup.type=redis").run { context -> - assertFalse(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - - @Test - fun `does not activate when type is unrecognized`() { - contextRunner.withPropertyValues("ogiri.lookup.type=memcached").run { context -> - assertFalse(context.containsBean("ogiriCaffeineTokenLookupCache")) - } - } - - @Test - fun `custom OgiriTokenLookupCache bean wins over auto-configured caffeine`() { - contextRunner - .withPropertyValues("ogiri.lookup.type=caffeine") - .withUserConfiguration(CustomLookupCacheConfig::class.java) - .run { context -> - assertFalse(context.containsBean("ogiriCaffeineTokenLookupCache")) - assertTrue(context.containsBean("customLookupCache")) - } - } - } - - @Configuration - class CustomLookupCacheConfig { - @Bean fun customLookupCache(): OgiriTokenLookupCache = StubLookupCache() - } - - private class StubLookupCache : OgiriTokenLookupCache { - override fun get(userId: Long, client: String): OgiriToken? = null - - override fun put(userId: Long, client: String, token: OgiriToken) {} - - override fun evict(userId: Long, client: String) {} - - override fun evictAll(userId: Long) {} - } -} diff --git a/ogiri-client/.oxfmtrc.json b/ogiri-client/.oxfmtrc.json deleted file mode 100644 index dc8fe91..0000000 --- a/ogiri-client/.oxfmtrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "indentWidth": 2, - "lineWidth": 100, - "quoteStyle": "single", - "trailingCommas": "all" -} diff --git a/ogiri-client/LICENSE b/ogiri-client/LICENSE deleted file mode 100644 index 1538916..0000000 --- a/ogiri-client/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025 Quanti Pixels - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/ogiri-client/README.md b/ogiri-client/README.md deleted file mode 100644 index bea41fc..0000000 --- a/ogiri-client/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Ògiri Security Client — Retired - -> **This package is no longer published.** -> -> The auth primitives have been inlined into the sample app and documented as copy-paste patterns. -> See [`sample/sample-react/src/lib/auth.ts`](../sample/sample-react/src/lib/auth.ts) and -> [`docs/react-integration.md`](../docs/react-integration.md). - -## Why - -The client was designed as "distroless" (zero mandatory dependencies, BYO HTTP client). Taken to its -conclusion, a 200-line auth primitive that consumers are encouraged to own doesn't belong in a -versioned npm package — it belongs in a sample they copy and adapt. - -The `ogiri-security-client/axios` sub-entrypoint made the same point more sharply: the axios -adapter is 60 lines of mechanical wiring that every project that uses it customises anyway. - -## What to use instead - -Copy [`src/lib/auth.ts`](../sample/sample-react/src/lib/auth.ts) and optionally -[`src/lib/axios-ogiri.ts`](../sample/sample-react/src/lib/axios-ogiri.ts) into your project. -They are self-contained, fully typed, and have no runtime dependencies. - -See [`docs/react-integration.md`](../docs/react-integration.md) for a complete integration guide -covering axios, ky, ofetch, and native fetch. - -## Source - -The source in `src/` is kept as a reference. It is not published to npm (`"private": true`). - -## License - -Apache License 2.0 diff --git a/ogiri-client/oxlintrc.json b/ogiri-client/oxlintrc.json deleted file mode 100644 index 4ac54cd..0000000 --- a/ogiri-client/oxlintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxlint-config-schema/main/.schema.json", - "rules": { - "no-console": "warn", - "no-debugger": "error", - "no-unused-vars": "warn", - "eqeqeq": "error", - "no-var": "error", - "prefer-const": "warn" - } -} diff --git a/ogiri-client/package.json b/ogiri-client/package.json deleted file mode 100644 index 9b855a7..0000000 --- a/ogiri-client/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "ogiri-security-client", - "version": "2.0.0", - "private": true, - "description": "Retired — auth primitives are inlined in sample-react. See docs/react-integration.md.", - "license": "Apache-2.0", - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsup", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit", - "lint": "oxlint src/ tests/", - "format": "oxfmt src/ tests/", - "format:check": "oxfmt --check src/ tests/" - }, - "keywords": [ - "ogiri", - "authentication", - "opaque-token", - "token-rotation", - "security" - ], - "repository": { - "type": "git", - "url": "https://github.com/quantipixels/ogiri.git", - "directory": "ogiri-client" - }, - "devDependencies": { - "@types/node": "^24.13.3", - "@vitest/coverage-v8": "^1.6.1", - "oxfmt": "^0.36.0", - "oxlint": "^1.51.0", - "tsup": "^8.0.0", - "typescript": "^5.3.0", - "vitest": "^1.2.0" - }, - "engines": { - "node": ">=22.12.0" - } -} diff --git a/ogiri-client/pnpm-lock.yaml b/ogiri-client/pnpm-lock.yaml deleted file mode 100644 index b3d2240..0000000 --- a/ogiri-client/pnpm-lock.yaml +++ /dev/null @@ -1,2348 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - '@types/node': - specifier: ^24.13.3 - version: 24.13.3 - '@vitest/coverage-v8': - specifier: ^1.6.1 - version: 1.6.1(vitest@1.6.1(@types/node@24.13.3)) - oxfmt: - specifier: ^0.36.0 - version: 0.36.0 - oxlint: - specifier: ^1.51.0 - version: 1.51.0 - tsup: - specifier: ^8.0.0 - version: 8.5.1(postcss@8.5.6)(typescript@5.9.3) - typescript: - specifier: ^5.3.0 - version: 5.9.3 - vitest: - specifier: ^1.2.0 - version: 1.6.1(@types/node@24.13.3) - -packages: - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@oxfmt/binding-android-arm-eabi@0.36.0': - resolution: {integrity: sha512-Z4yVHJWx/swHHjtr0dXrBZb6LxS+qNz1qdza222mWwPTUK4L790+5i3LTgjx3KYGBzcYpjaiZBw4vOx94dH7MQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm64@0.36.0': - resolution: {integrity: sha512-3ElCJRFNPQl7jexf2CAa9XmAm8eC5JPrIDSjc9jSchkVSFTEqyL0NtZinBB2h1a4i4JgP1oGl/5G5n8YR4FN8Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-darwin-arm64@0.36.0': - resolution: {integrity: sha512-nak4znWCqIExKhYSY/mz/lWsqWIpdsS7o0+SRzXR1Q0m7GrMcG1UrF1pS7TLGZhhkf7nTfEF7q6oZzJiodRDuw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.36.0': - resolution: {integrity: sha512-V4GP96thDnpKx6ADnMDnhIXNdtV+Ql9D4HUU+a37VTeVbs5qQSF/s6hhUP1b3xUqU7iRcwh72jUU2Y12rtGHAw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-freebsd-x64@0.36.0': - resolution: {integrity: sha512-/xapWCADfI5wrhxpEUjhI9fnw7MV5BUZizVa8e24n3VSK6A3Y1TB/ClOP1tfxNspykFKXp4NBWl6NtDJP3osqQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-linux-arm-gnueabihf@0.36.0': - resolution: {integrity: sha512-1lOmv61XMFIH5uNm27620kRRzWt/RK6tdn250BRDoG9W7OXGOQ5UyI1HVT+SFkoOoKztBiinWgi68+NA1MjBVQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.36.0': - resolution: {integrity: sha512-vMH23AskdR1ujUS9sPck2Df9rBVoZUnCVY86jisILzIQ/QQ/yKUTi7tgnIvydPx7TyB/48wsQ5QMr5Knq5p/aw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm64-gnu@0.36.0': - resolution: {integrity: sha512-Hy1V+zOBHpBiENRx77qrUTt5aPDHeCASRc8K5KwwAHkX2AKP0nV89eL17hsZrE9GmnXFjsNmd80lyf7aRTXsbw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-arm64-musl@0.36.0': - resolution: {integrity: sha512-SPGLJkOIHSIC6ABUQ5V8NqJpvYhMJueJv26NYqfCnwi/Mn6A61amkpJJ9Suy0Nmvs+OWESJpcebrBUbXPGZyQQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-ppc64-gnu@0.36.0': - resolution: {integrity: sha512-3EuoyB8x9x8ysYJjbEO/M9fkSk72zQKnXCvpZMDHXlnY36/1qMp55Nm0PrCwjGO/1pen5hdOVkz9WmP3nAp2IQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-gnu@0.36.0': - resolution: {integrity: sha512-MpY3itLwpGh8dnywtrZtaZ604T1m715SydCKy0+qTxetv+IHzuA+aO/AGzrlzUNYZZmtWtmDBrChZGibvZxbRQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-musl@0.36.0': - resolution: {integrity: sha512-mmDhe4Vtx+XwQPRPn/V25+APnkApYgZ23q+6GVsNYY98pf3aU0aI3Me96pbRs/AfJ1jIiGC+/6q71FEu8dHcHw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-s390x-gnu@0.36.0': - resolution: {integrity: sha512-AYXhU+DmNWLSnvVwkHM92fuYhogtVHab7UQrPNaDf1sxadugg9gWVmcgJDlIwxJdpk5CVW/TFvwUKwI432zhhA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-gnu@0.36.0': - resolution: {integrity: sha512-H16QhhQ3usoakMleiAAQ2mg0NsBDAdyE9agUgfC8IHHh3jZEbr0rIKwjEqwbOHK5M0EmfhJmr+aGO/MgZPsneA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-musl@0.36.0': - resolution: {integrity: sha512-EFFGkixA39BcmHiCe2ECdrq02D6FCve5ka6ObbvrheXl4V+R0U/E+/uLyVx1X65LW8TA8QQHdnbdDallRekohw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-openharmony-arm64@0.36.0': - resolution: {integrity: sha512-zr/t369wZWFOj1qf06Z5gGNjFymfUNDrxKMmr7FKiDRVI1sNsdKRCuRL4XVjtcptKQ+ao3FfxLN1vrynivmCYg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-win32-arm64-msvc@0.36.0': - resolution: {integrity: sha512-FxO7UksTv8h4olzACgrqAXNF6BP329+H322323iDrMB5V/+a1kcAw07fsOsUmqNrb9iJBsCQgH/zqcqp5903ag==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.36.0': - resolution: {integrity: sha512-OjoMQ89H01M0oLMfr/CPNH1zi48ZIwxAKObUl57oh7ssUBNDp/2Vjf7E1TQ8M4oj4VFQ/byxl2SmcPNaI2YNDg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.36.0': - resolution: {integrity: sha512-MoyeQ9S36ZTz/4bDhOKJgOBIDROd4dQ5AkT9iezhEaUBxAPdNX9Oq0jD8OSnCj3G4wam/XNxVWKMA52kmzmPtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint/binding-android-arm-eabi@1.51.0': - resolution: {integrity: sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm64@1.51.0': - resolution: {integrity: sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.51.0': - resolution: {integrity: sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.51.0': - resolution: {integrity: sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxlint/binding-freebsd-x64@1.51.0': - resolution: {integrity: sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.51.0': - resolution: {integrity: sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.51.0': - resolution: {integrity: sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.51.0': - resolution: {integrity: sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-musl@1.51.0': - resolution: {integrity: sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-ppc64-gnu@1.51.0': - resolution: {integrity: sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.51.0': - resolution: {integrity: sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-musl@1.51.0': - resolution: {integrity: sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-s390x-gnu@1.51.0': - resolution: {integrity: sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.51.0': - resolution: {integrity: sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-musl@1.51.0': - resolution: {integrity: sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-openharmony-arm64@1.51.0': - resolution: {integrity: sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.51.0': - resolution: {integrity: sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.51.0': - resolution: {integrity: sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.51.0': - resolution: {integrity: sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} - cpu: [x64] - os: [win32] - - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - - '@vitest/coverage-v8@1.6.1': - resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} - peerDependencies: - vitest: 1.6.1 - - '@vitest/expect@1.6.1': - resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} - - '@vitest/runner@1.6.1': - resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} - - '@vitest/snapshot@1.6.1': - resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} - - '@vitest/spy@1.6.1': - resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} - - '@vitest/utils@1.6.1': - resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} - - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} - - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - local-pkg@0.5.1: - resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} - engines: {node: '>=14'} - - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - oxfmt@0.36.0: - resolution: {integrity: sha512-/ejJ+KoSW6J9bcNT9a9UtJSJNWhJ3yOLSBLbkoFHJs/8CZjmaZVZAJe4YgO1KMJlKpNQasrn/G9JQUEZI3p0EQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - oxlint@1.51.0: - resolution: {integrity: sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.15.0' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - - strip-literal@2.1.1: - resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} - engines: {node: '>=14.0.0'} - - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} - engines: {node: ^20.0.0 || >=22.0.0} - - tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} - engines: {node: '>=14.0.0'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - vite-node@1.6.1: - resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vitest@1.6.1: - resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.1 - '@vitest/ui': 1.6.1 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - -snapshots: - - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@0.2.3': {} - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@istanbuljs/schema@0.1.3': {} - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.10 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@oxfmt/binding-android-arm-eabi@0.36.0': - optional: true - - '@oxfmt/binding-android-arm64@0.36.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.36.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.36.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.36.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.36.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.36.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.36.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.36.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.36.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.36.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.36.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.36.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.36.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.36.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.36.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.36.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.36.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.36.0': - optional: true - - '@oxlint/binding-android-arm-eabi@1.51.0': - optional: true - - '@oxlint/binding-android-arm64@1.51.0': - optional: true - - '@oxlint/binding-darwin-arm64@1.51.0': - optional: true - - '@oxlint/binding-darwin-x64@1.51.0': - optional: true - - '@oxlint/binding-freebsd-x64@1.51.0': - optional: true - - '@oxlint/binding-linux-arm-gnueabihf@1.51.0': - optional: true - - '@oxlint/binding-linux-arm-musleabihf@1.51.0': - optional: true - - '@oxlint/binding-linux-arm64-gnu@1.51.0': - optional: true - - '@oxlint/binding-linux-arm64-musl@1.51.0': - optional: true - - '@oxlint/binding-linux-ppc64-gnu@1.51.0': - optional: true - - '@oxlint/binding-linux-riscv64-gnu@1.51.0': - optional: true - - '@oxlint/binding-linux-riscv64-musl@1.51.0': - optional: true - - '@oxlint/binding-linux-s390x-gnu@1.51.0': - optional: true - - '@oxlint/binding-linux-x64-gnu@1.51.0': - optional: true - - '@oxlint/binding-linux-x64-musl@1.51.0': - optional: true - - '@oxlint/binding-openharmony-arm64@1.51.0': - optional: true - - '@oxlint/binding-win32-arm64-msvc@1.51.0': - optional: true - - '@oxlint/binding-win32-ia32-msvc@1.51.0': - optional: true - - '@oxlint/binding-win32-x64-msvc@1.51.0': - optional: true - - '@rollup/rollup-android-arm-eabi@4.57.1': - optional: true - - '@rollup/rollup-android-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-x64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.57.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.57.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.57.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.57.1': - optional: true - - '@sinclair/typebox@0.27.10': {} - - '@types/estree@1.0.8': {} - - '@types/node@24.13.3': - dependencies: - undici-types: 7.18.2 - - '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@24.13.3))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 - test-exclude: 6.0.0 - vitest: 1.6.1(@types/node@24.13.3) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@1.6.1': - dependencies: - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - chai: 4.5.0 - - '@vitest/runner@1.6.1': - dependencies: - '@vitest/utils': 1.6.1 - p-limit: 5.0.0 - pathe: 1.1.2 - - '@vitest/snapshot@1.6.1': - dependencies: - magic-string: 0.30.21 - pathe: 1.1.2 - pretty-format: 29.7.0 - - '@vitest/spy@1.6.1': - dependencies: - tinyspy: 2.2.1 - - '@vitest/utils@1.6.1': - dependencies: - diff-sequences: 29.6.3 - estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 - - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - ansi-styles@5.2.0: {} - - any-promise@1.3.0: {} - - assertion-error@1.1.0: {} - - balanced-match@1.0.2: {} - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - bundle-require@5.1.0(esbuild@0.27.3): - dependencies: - esbuild: 0.27.3 - load-tsconfig: 0.2.5 - - cac@6.7.14: {} - - chai@4.5.0: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 - - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - commander@4.1.1: {} - - concat-map@0.0.1: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 - - diff-sequences@29.6.3: {} - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - get-func-name@2.0.2: {} - - get-stream@8.0.1: {} - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - has-flag@4.0.0: {} - - html-escaper@2.0.2: {} - - human-signals@5.0.0: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-stream@3.0.0: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - joycon@3.1.1: {} - - js-tokens@9.0.1: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - local-pkg@0.5.1: - dependencies: - mlly: 1.8.0 - pkg-types: 1.3.1 - - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.3.5: - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - - merge-stream@2.0.0: {} - - mimic-fn@4.0.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.11: {} - - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - - object-assign@4.1.1: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - oxfmt@0.36.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.36.0 - '@oxfmt/binding-android-arm64': 0.36.0 - '@oxfmt/binding-darwin-arm64': 0.36.0 - '@oxfmt/binding-darwin-x64': 0.36.0 - '@oxfmt/binding-freebsd-x64': 0.36.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.36.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.36.0 - '@oxfmt/binding-linux-arm64-gnu': 0.36.0 - '@oxfmt/binding-linux-arm64-musl': 0.36.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.36.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.36.0 - '@oxfmt/binding-linux-riscv64-musl': 0.36.0 - '@oxfmt/binding-linux-s390x-gnu': 0.36.0 - '@oxfmt/binding-linux-x64-gnu': 0.36.0 - '@oxfmt/binding-linux-x64-musl': 0.36.0 - '@oxfmt/binding-openharmony-arm64': 0.36.0 - '@oxfmt/binding-win32-arm64-msvc': 0.36.0 - '@oxfmt/binding-win32-ia32-msvc': 0.36.0 - '@oxfmt/binding-win32-x64-msvc': 0.36.0 - - oxlint@1.51.0: - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.51.0 - '@oxlint/binding-android-arm64': 1.51.0 - '@oxlint/binding-darwin-arm64': 1.51.0 - '@oxlint/binding-darwin-x64': 1.51.0 - '@oxlint/binding-freebsd-x64': 1.51.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.51.0 - '@oxlint/binding-linux-arm-musleabihf': 1.51.0 - '@oxlint/binding-linux-arm64-gnu': 1.51.0 - '@oxlint/binding-linux-arm64-musl': 1.51.0 - '@oxlint/binding-linux-ppc64-gnu': 1.51.0 - '@oxlint/binding-linux-riscv64-gnu': 1.51.0 - '@oxlint/binding-linux-riscv64-musl': 1.51.0 - '@oxlint/binding-linux-s390x-gnu': 1.51.0 - '@oxlint/binding-linux-x64-gnu': 1.51.0 - '@oxlint/binding-linux-x64-musl': 1.51.0 - '@oxlint/binding-openharmony-arm64': 1.51.0 - '@oxlint/binding-win32-arm64-msvc': 1.51.0 - '@oxlint/binding-win32-ia32-msvc': 1.51.0 - '@oxlint/binding-win32-x64-msvc': 1.51.0 - - p-limit@5.0.0: - dependencies: - yocto-queue: 1.2.2 - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-key@4.0.0: {} - - pathe@1.1.2: {} - - pathe@2.0.3: {} - - pathval@1.1.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - pirates@4.0.7: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - postcss-load-config@6.0.1(postcss@8.5.6): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - postcss: 8.5.6 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - react-is@18.3.1: {} - - readdirp@4.1.2: {} - - resolve-from@5.0.0: {} - - rollup@4.57.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 - fsevents: 2.3.3 - - semver@7.7.4: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} - - source-map-js@1.2.1: {} - - source-map@0.7.6: {} - - stackback@0.0.2: {} - - std-env@3.10.0: {} - - strip-final-newline@3.0.0: {} - - strip-literal@2.1.1: - dependencies: - js-tokens: 9.0.1 - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tinypool@0.8.4: {} - - tinypool@2.1.0: {} - - tinyspy@2.2.1: {} - - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} - - tsup@8.5.1(postcss@8.5.6)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.3) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.3 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6) - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - type-detect@4.1.0: {} - - typescript@5.9.3: {} - - ufo@1.6.3: {} - - undici-types@7.18.2: {} - - vite-node@1.6.1(@types/node@24.13.3): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.21(@types/node@24.13.3) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@24.13.3): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.57.1 - optionalDependencies: - '@types/node': 24.13.3 - fsevents: 2.3.3 - - vitest@1.6.1(@types/node@24.13.3): - dependencies: - '@vitest/expect': 1.6.1 - '@vitest/runner': 1.6.1 - '@vitest/snapshot': 1.6.1 - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - acorn-walk: 8.3.4 - chai: 4.5.0 - debug: 4.4.3 - execa: 8.0.1 - local-pkg: 0.5.1 - magic-string: 0.30.21 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 - tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.4.21(@types/node@24.13.3) - vite-node: 1.6.1(@types/node@24.13.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.3 - transitivePeerDependencies: - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wrappy@1.0.2: {} - - yocto-queue@1.2.2: {} diff --git a/ogiri-client/src/auth.ts b/ogiri-client/src/auth.ts deleted file mode 100644 index 479b8c6..0000000 --- a/ogiri-client/src/auth.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { OgiriAuthConfig, OgiriAuthMethod, OgiriTokens, TokenStorage } from "./types"; -import { MemoryTokenStorage } from "./token-storage"; -import { injectAuth, extractTokens } from "./interceptors"; -import { OgiriAuthError } from "./errors"; -import { OgiriFetchClient } from "./fetch-client"; - -type AuthErrorCallback = (error: OgiriAuthError) => void; -type AuthChangeListener = () => void; - -/** - * Central auth primitive — manages token state and provides adapter factories. - * Zero runtime dependencies. Consumers bring their own HTTP client. - */ -export class OgiriAuth { - private readonly authMethod: OgiriAuthMethod; - private readonly storage: TokenStorage; - private authErrorCallback?: AuthErrorCallback; - private listeners: Set = new Set(); - - constructor(config: OgiriAuthConfig = {}) { - this.authMethod = config.authMethod ?? "headers"; - this.storage = config.storage ?? new MemoryTokenStorage(); - this.authErrorCallback = config.onAuthError; - } - - getTokens(): OgiriTokens | null { - return this.storage.get(); - } - - setTokens(tokens: OgiriTokens): void { - this.storage.set(tokens); - this.notifyListeners(); - } - - clearTokens(): void { - this.storage.clear(); - this.notifyListeners(); - } - - isAuthenticated(): boolean { - return this.storage.get() !== null; - } - - onAuthError(callback: AuthErrorCallback): void { - this.authErrorCallback = callback; - } - - /** - * Subscribe to auth state changes (token set/clear). - * Returns unsubscribe function. - */ - subscribe(listener: AuthChangeListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - /** - * Inject auth tokens into a fetch RequestInit config. - */ - injectInto(config: RequestInit): RequestInit { - const tokens = this.storage.get(); - if (!tokens) return config; - return injectAuth(config, tokens, this.authMethod); - } - - /** - * Extract rotated tokens from a fetch Response and store them. - */ - extractFrom(response: Response): void { - const rotated = extractTokens(response); - if (rotated) { - this.storage.set(rotated); - this.notifyListeners(); - } - } - - /** - * Handle a 401 response — clears tokens and fires the error callback. - */ - handleAuthError(body: unknown): OgiriAuthError { - this.storage.clear(); - this.notifyListeners(); - const error = new OgiriAuthError("Unauthorized", 401, body); - this.authErrorCallback?.(error); - return error; - } - - /** - * Returns a function that injects auth headers into a plain headers object. - * Useful for BYO HTTP clients (ky, ofetch, etc.). - */ - headerInjector(): (headers: Record) => Record { - return (headers: Record) => { - const injected = this.injectInto({ headers }); - return (injected.headers as Record) ?? headers; - }; - } - - /** - * Factory for the convenience fetch client. - */ - createFetchClient(baseURL: string): OgiriFetchClient { - return new OgiriFetchClient(this, baseURL); - } - - private notifyListeners(): void { - for (const listener of this.listeners) { - listener(); - } - } -} diff --git a/ogiri-client/src/errors.ts b/ogiri-client/src/errors.ts deleted file mode 100644 index ba81614..0000000 --- a/ogiri-client/src/errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Authentication error thrown when requests fail with 401 - */ -export class OgiriAuthError extends Error { - constructor( - message: string, - public readonly status: number, - public readonly body: unknown, - ) { - super(message); - this.name = "OgiriAuthError"; - } -} diff --git a/ogiri-client/src/fetch-client.ts b/ogiri-client/src/fetch-client.ts deleted file mode 100644 index cbd893e..0000000 --- a/ogiri-client/src/fetch-client.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { OgiriRequestOptions, OgiriResponse } from "./types"; -import type { OgiriAuth } from "./auth"; - -/** - * Thin convenience wrapper over native fetch. - * Delegates all auth state management to OgiriAuth. - * Optional — consumers who use axios/ky/ofetch can ignore this entirely. - */ -export class OgiriFetchClient { - private readonly baseURL: string; - - constructor( - private readonly auth: OgiriAuth, - baseURL: string, - ) { - this.baseURL = baseURL.replace(/\/$/, ""); - } - - async request( - path: string, - options: OgiriRequestOptions & { method?: string } = {}, - ): Promise> { - const { params, body, method = "GET", ...fetchOptions } = options; - - let url = `${this.baseURL}${path}`; - if (params) { - const query = new URLSearchParams(params).toString(); - url += `?${query}`; - } - - let config: RequestInit = { - ...fetchOptions, - method, - }; - - if (body) { - config.body = JSON.stringify(body); - config.headers = { - ...config.headers, - "Content-Type": "application/json", - }; - } - - config = this.auth.injectInto(config); - - let response: Response; - try { - response = await fetch(url, config); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Network request failed for ${method} ${url}: ${message}`); - } - - if (response.status === 401) { - const errorBody = await this.safeParseJson(response); - throw this.auth.handleAuthError(errorBody); - } - - if (!response.ok) { - const errorBody = await this.safeParseJson(response); - const errorMessage = - typeof errorBody === "object" && errorBody !== null && "message" in errorBody - ? String((errorBody as { message: unknown }).message) - : response.statusText; - const error = new Error(`HTTP ${response.status}: ${errorMessage}`); - (error as any).status = response.status; - (error as any).body = errorBody; - throw error; - } - - this.auth.extractFrom(response); - - const data = await this.safeParseJson(response); - - if (typeof data === "string") { - throw new Error(`Expected JSON from ${url}, received unparseable text: ${data.substring(0, 100)}`); - } - - return { data: data as T, response }; - } - - async get(path: string, options?: OgiriRequestOptions): Promise> { - return this.request(path, { ...options, method: "GET" }); - } - - async post(path: string, body: unknown, options?: OgiriRequestOptions): Promise> { - return this.request(path, { ...options, method: "POST", body }); - } - - async put(path: string, body: unknown, options?: OgiriRequestOptions): Promise> { - return this.request(path, { ...options, method: "PUT", body }); - } - - async delete(path: string, options?: OgiriRequestOptions): Promise> { - return this.request(path, { ...options, method: "DELETE" }); - } - - private async safeParseJson(response: Response): Promise { - const text = await response.text(); - if (!text) return null; - try { - return JSON.parse(text); - } catch (e) { - console.error(`Failed to parse JSON response: ${e instanceof Error ? e.message : String(e)}`); - return text; - } - } -} diff --git a/ogiri-client/src/index.ts b/ogiri-client/src/index.ts deleted file mode 100644 index 888aafc..0000000 --- a/ogiri-client/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { OgiriAuth } from "./auth"; -export { OgiriFetchClient } from "./fetch-client"; -export { OgiriAuthError } from "./errors"; -export { MemoryTokenStorage, LocalStorageTokenStorage } from "./token-storage"; -export { injectAuth, extractTokens } from "./interceptors"; -export type { - OgiriTokens, - OgiriAuthMethod, - TokenStorage, - OgiriAuthConfig, - OgiriClientConfig, - OgiriRequestOptions, - OgiriResponse, -} from "./types"; diff --git a/ogiri-client/src/interceptors.ts b/ogiri-client/src/interceptors.ts deleted file mode 100644 index a380789..0000000 --- a/ogiri-client/src/interceptors.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { OgiriTokens, OgiriAuthMethod } from "./types"; - -/** - * Inject authentication into request config - */ -export function injectAuth(config: RequestInit, tokens: OgiriTokens, method: OgiriAuthMethod): RequestInit { - const headers = normalizeHeaders(config.headers); - - if (method === "headers") { - const authHeaders: Record = { - "access-token": tokens.accessToken, - client: tokens.client, - uid: tokens.uid, - expiry: tokens.expiry, - "token-type": tokens.tokenType, - }; - - // H4: Include access-token-kind if present - if (tokens.tokenKind) { - authHeaders["access-token-kind"] = tokens.tokenKind; - } - - return { - ...config, - headers: { - ...headers, - ...authHeaders, - }, - }; - } - - if (method === "bearer") { - const payload: Record = { - "access-token": tokens.accessToken, - client: tokens.client, - uid: tokens.uid, - "token-type": tokens.tokenType, - expiry: tokens.expiry, - }; - - // H4: Include access-token-kind if present - if (tokens.tokenKind) { - payload["access-token-kind"] = tokens.tokenKind; - } - - const jsonStr = JSON.stringify(payload); - - // H3: Handle non-ASCII characters in btoa - const base64 = - typeof Buffer !== "undefined" - ? Buffer.from(jsonStr).toString("base64") - : btoa(unescape(encodeURIComponent(jsonStr))); - - return { - ...config, - headers: { - ...headers, - Authorization: `Bearer ${base64}`, - }, - }; - } - - if (method === "cookies") { - // C4: Cookie header is forbidden in browsers, warn and use credentials only - if (typeof window !== "undefined") { - console.warn( - "Cookie authentication method is not supported in browsers. " + - "The browser will ignore the Cookie header. " + - 'Use "headers" or "bearer" method instead, or rely on server-set cookies with credentials: "include".', - ); - } - - const cookieParts = [ - `access-token=${tokens.accessToken}`, - `client=${tokens.client}`, - `uid=${tokens.uid}`, - `expiry=${tokens.expiry}`, - `token-type=${tokens.tokenType}`, - ]; - - // H4: Include access-token-kind if present - if (tokens.tokenKind) { - cookieParts.push(`access-token-kind=${tokens.tokenKind}`); - } - - const cookies = cookieParts.join("; "); - - return { - ...config, - credentials: "include", - headers: { - ...headers, - Cookie: cookies, - }, - }; - } - - // M2: Exhaustive check for unmatched method - const _exhaustive: never = method; - throw new Error(`Unknown auth method: ${String(_exhaustive)}`); -} - -/** - * Extract rotated tokens from response headers - */ -export function extractTokens(response: Response): OgiriTokens | null { - const accessToken = response.headers.get("access-token"); - const client = response.headers.get("client"); - const uid = response.headers.get("uid"); - const expiry = response.headers.get("expiry"); - const tokenType = response.headers.get("token-type"); - const tokenKind = response.headers.get("access-token-kind"); // H4: Optional - - // M1: Warn on partial token headers (likely config issue) - const presentHeaders = [accessToken, client, uid, expiry, tokenType].filter(Boolean); - if (presentHeaders.length > 0 && presentHeaders.length < 5) { - console.warn( - `Partial token rotation headers detected (${presentHeaders.length}/5 present). ` + - "This may indicate a proxy stripping headers or incomplete server response. " + - "Token rotation will be skipped.", - ); - } - - if (!accessToken || !client || !uid || !expiry || !tokenType) { - return null; - } - - const tokens: OgiriTokens = { - accessToken, - client, - uid, - expiry, - tokenType, - }; - - // H4: Include tokenKind if present - if (tokenKind) { - tokens.tokenKind = tokenKind; - } - - return tokens; -} - -/** - * Normalize headers to plain object - */ -function normalizeHeaders(headers?: HeadersInit): Record { - if (!headers) return {}; - - if (headers instanceof Headers) { - const normalized: Record = {}; - headers.forEach((value, key) => { - normalized[key] = value; - }); - return normalized; - } - - if (Array.isArray(headers)) { - return Object.fromEntries(headers); - } - - return headers as Record; -} diff --git a/ogiri-client/src/token-storage.ts b/ogiri-client/src/token-storage.ts deleted file mode 100644 index 5b45440..0000000 --- a/ogiri-client/src/token-storage.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { OgiriTokens, TokenStorage } from "./types"; - -/** - * In-memory token storage (SSR-safe, default) - */ -export class MemoryTokenStorage implements TokenStorage { - private tokens: OgiriTokens | null = null; - - get(): OgiriTokens | null { - return this.tokens; - } - - set(tokens: OgiriTokens): void { - this.tokens = tokens; - } - - clear(): void { - this.tokens = null; - } -} - -/** - * Browser localStorage-based token storage - */ -export class LocalStorageTokenStorage implements TokenStorage { - constructor(private readonly key: string = "ogiri-tokens") {} - - get(): OgiriTokens | null { - try { - const stored = localStorage.getItem(this.key); - return stored ? JSON.parse(stored) : null; - } catch (err) { - // C3: Distinguish between JSON parse errors and storage access errors - if (err instanceof SyntaxError) { - console.warn("Invalid JSON in token storage, clearing..."); - try { - localStorage.removeItem(this.key); - } catch { - // Ignore errors when clearing - } - return null; - } - // Storage access error (blocked, quota exceeded, etc.) - console.error("Failed to access localStorage:", err instanceof Error ? err.message : String(err)); - return null; - } - } - - set(tokens: OgiriTokens): void { - // C2: Handle localStorage errors (QuotaExceededError, SecurityError) - try { - localStorage.setItem(this.key, JSON.stringify(tokens)); - } catch (err) { - console.error("Failed to save tokens to localStorage:", err instanceof Error ? err.message : String(err)); - // Don't throw - storage failure should not kill the API response - } - } - - clear(): void { - // C2: Handle localStorage errors during clear - try { - localStorage.removeItem(this.key); - } catch (err) { - console.error( - "Failed to clear tokens from localStorage:", - err instanceof Error ? err.message : String(err), - ); - // Don't throw - this is called from 401 path, must not block error handling - } - } -} diff --git a/ogiri-client/src/types.ts b/ogiri-client/src/types.ts deleted file mode 100644 index 1af22f7..0000000 --- a/ogiri-client/src/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Ogiri authentication tokens (camelCase properties match server headers) - */ -export interface OgiriTokens { - accessToken: string; - client: string; - uid: string; - expiry: string; - tokenType: string; - /** H4: Token kind (APP, SUB, etc.) - sent as access-token-kind header */ - tokenKind?: string; -} - -/** - * Authentication method for Ogiri requests - */ -export type OgiriAuthMethod = "headers" | "bearer" | "cookies"; - -/** - * Token storage interface - */ -export interface TokenStorage { - get(): OgiriTokens | null; - set(tokens: OgiriTokens): void; - clear(): void; -} - -/** - * Auth configuration (used by OgiriAuth — the primary API) - */ -export interface OgiriAuthConfig { - authMethod?: OgiriAuthMethod; - storage?: TokenStorage; - onAuthError?: (error: OgiriAuthError) => void; -} - -/** - * Client configuration (used by OgiriFetchClient — the convenience wrapper) - */ -export interface OgiriClientConfig { - baseURL: string; - authMethod?: OgiriAuthMethod; - storage?: TokenStorage; - /** M3: Callback receives OgiriAuthError, not generic Error */ - onAuthError?: (error: OgiriAuthError) => void; -} - -// Forward declaration for OgiriAuthError type -export interface OgiriAuthError extends Error { - status: number; - body: unknown; -} - -/** - * Request options - */ -export interface OgiriRequestOptions extends Omit { - params?: Record; - body?: unknown; -} - -/** - * Response wrapper - */ -export interface OgiriResponse { - data: T; - response: Response; -} diff --git a/ogiri-client/tests/auth.test.ts b/ogiri-client/tests/auth.test.ts deleted file mode 100644 index c3ad1c7..0000000 --- a/ogiri-client/tests/auth.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { OgiriAuth } from "../src/auth"; -import { OgiriAuthError } from "../src/errors"; -import type { OgiriTokens } from "../src/types"; - -const mockTokens: OgiriTokens = { - accessToken: "test-token", - client: "test-client", - uid: "user@example.com", - expiry: "1234567890", - tokenType: "Bearer", -}; - -describe("OgiriAuth", () => { - describe("token state", () => { - it("should start unauthenticated", () => { - const auth = new OgiriAuth(); - expect(auth.isAuthenticated()).toBe(false); - expect(auth.getTokens()).toBeNull(); - }); - - it("should set and get tokens", () => { - const auth = new OgiriAuth(); - auth.setTokens(mockTokens); - expect(auth.isAuthenticated()).toBe(true); - expect(auth.getTokens()).toEqual(mockTokens); - }); - - it("should clear tokens", () => { - const auth = new OgiriAuth(); - auth.setTokens(mockTokens); - auth.clearTokens(); - expect(auth.isAuthenticated()).toBe(false); - expect(auth.getTokens()).toBeNull(); - }); - }); - - describe("subscribe", () => { - it("should notify listeners on setTokens", () => { - const auth = new OgiriAuth(); - const listener = vi.fn(); - auth.subscribe(listener); - - auth.setTokens(mockTokens); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it("should notify listeners on clearTokens", () => { - const auth = new OgiriAuth(); - auth.setTokens(mockTokens); - const listener = vi.fn(); - auth.subscribe(listener); - - auth.clearTokens(); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it("should stop notifying after unsubscribe", () => { - const auth = new OgiriAuth(); - const listener = vi.fn(); - const unsub = auth.subscribe(listener); - - auth.setTokens(mockTokens); - expect(listener).toHaveBeenCalledTimes(1); - - unsub(); - auth.clearTokens(); - expect(listener).toHaveBeenCalledTimes(1); - }); - }); - - describe("injectInto", () => { - it("should return config unchanged when no tokens", () => { - const auth = new OgiriAuth(); - const config: RequestInit = { method: "GET" }; - const result = auth.injectInto(config); - expect(result).toBe(config); - }); - - it("should inject auth headers when tokens exist", () => { - const auth = new OgiriAuth({ authMethod: "headers" }); - auth.setTokens(mockTokens); - - const result = auth.injectInto({}); - const headers = result.headers as Record; - - expect(headers["access-token"]).toBe("test-token"); - expect(headers.client).toBe("test-client"); - expect(headers.uid).toBe("user@example.com"); - expect(headers.expiry).toBe("1234567890"); - expect(headers["token-type"]).toBe("Bearer"); - }); - - it("should support bearer auth method", () => { - const auth = new OgiriAuth({ authMethod: "bearer" }); - auth.setTokens(mockTokens); - - const result = auth.injectInto({}); - const headers = result.headers as Record; - expect(headers.Authorization).toMatch(/^Bearer /); - }); - }); - - describe("extractFrom", () => { - it("should extract and store rotated tokens from response", () => { - const auth = new OgiriAuth(); - const response = new Response(null, { - headers: new Headers({ - "access-token": "rotated-token", - client: "rotated-client", - uid: "user@example.com", - expiry: "9999999999", - "token-type": "Bearer", - }), - }); - - auth.extractFrom(response); - - expect(auth.getTokens()).toEqual({ - accessToken: "rotated-token", - client: "rotated-client", - uid: "user@example.com", - expiry: "9999999999", - tokenType: "Bearer", - }); - }); - - it("should not update tokens when headers are missing", () => { - const auth = new OgiriAuth(); - auth.setTokens(mockTokens); - const response = new Response(null); - - auth.extractFrom(response); - expect(auth.getTokens()).toEqual(mockTokens); - }); - - it("should notify listeners when tokens rotate", () => { - const auth = new OgiriAuth(); - const listener = vi.fn(); - auth.subscribe(listener); - - const response = new Response(null, { - headers: new Headers({ - "access-token": "new-token", - client: "new-client", - uid: "user@example.com", - expiry: "9999999999", - "token-type": "Bearer", - }), - }); - - auth.extractFrom(response); - expect(listener).toHaveBeenCalledTimes(1); - }); - }); - - describe("handleAuthError", () => { - it("should clear tokens and return OgiriAuthError", () => { - const auth = new OgiriAuth(); - auth.setTokens(mockTokens); - - const error = auth.handleAuthError({ error: "Unauthorized" }); - - expect(auth.isAuthenticated()).toBe(false); - expect(error).toBeInstanceOf(OgiriAuthError); - expect(error.status).toBe(401); - expect(error.body).toEqual({ error: "Unauthorized" }); - }); - - it("should fire onAuthError callback", () => { - const onAuthError = vi.fn(); - const auth = new OgiriAuth({ onAuthError }); - auth.setTokens(mockTokens); - - auth.handleAuthError(null); - - expect(onAuthError).toHaveBeenCalledWith(expect.any(OgiriAuthError)); - }); - }); - - describe("headerInjector", () => { - it("should return a function that adds auth headers", () => { - const auth = new OgiriAuth({ authMethod: "headers" }); - auth.setTokens(mockTokens); - - const inject = auth.headerInjector(); - const result = inject({ "Content-Type": "application/json" }); - - expect(result["Content-Type"]).toBe("application/json"); - expect(result["access-token"]).toBe("test-token"); - expect(result.client).toBe("test-client"); - }); - - it("should return original headers when unauthenticated", () => { - const auth = new OgiriAuth(); - const inject = auth.headerInjector(); - const headers = { "Content-Type": "application/json" }; - const result = inject(headers); - - expect(result).toEqual(headers); - }); - }); -}); diff --git a/ogiri-client/tests/fetch-client.test.ts b/ogiri-client/tests/fetch-client.test.ts deleted file mode 100644 index 729cfc1..0000000 --- a/ogiri-client/tests/fetch-client.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { OgiriAuth } from "../src/auth"; -import { OgiriFetchClient } from "../src/fetch-client"; -import { OgiriAuthError } from "../src/errors"; -import type { OgiriTokens } from "../src/types"; - -const mockTokens: OgiriTokens = { - accessToken: "test-token", - client: "test-client", - uid: "user@example.com", - expiry: "1234567890", - tokenType: "Bearer", -}; - -describe("OgiriFetchClient", () => { - let auth: OgiriAuth; - let client: OgiriFetchClient; - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - auth = new OgiriAuth(); - client = new OgiriFetchClient(auth, "https://api.example.com"); - }); - - describe("request", () => { - it("should make unauthenticated request", async () => { - const mockResponse = { data: "test" }; - fetchMock.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - - const result = await client.request("/test"); - - expect(fetchMock).toHaveBeenCalledWith( - "https://api.example.com/test", - expect.objectContaining({ method: "GET" }), - ); - expect(result.data).toEqual(mockResponse); - }); - - it("should inject auth headers when authenticated", async () => { - auth.setTokens(mockTokens); - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); - - await client.request("/protected"); - - const callArgs = fetchMock.mock.calls[0]; - const headers = callArgs[1].headers; - - expect(headers["access-token"]).toBe("test-token"); - expect(headers.client).toBe("test-client"); - expect(headers.uid).toBe("user@example.com"); - }); - - it("should extract and store rotated tokens from response", async () => { - auth.setTokens(mockTokens); - - fetchMock.mockResolvedValue( - new Response("{}", { - status: 200, - headers: { - "access-token": "rotated-token", - client: "rotated-client", - uid: "user@example.com", - expiry: "9999999999", - "token-type": "Bearer", - }, - }), - ); - - await client.request("/test"); - - expect(auth.getTokens()).toEqual({ - accessToken: "rotated-token", - client: "rotated-client", - uid: "user@example.com", - expiry: "9999999999", - tokenType: "Bearer", - }); - }); - - it("should send JSON body", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); - - await client.request("/test", { - method: "POST", - body: { foo: "bar" }, - }); - - const callArgs = fetchMock.mock.calls[0]; - expect(callArgs[1].body).toBe('{"foo":"bar"}'); - expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); - }); - - it("should handle query params", async () => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); - - await client.request("/test", { - params: { foo: "bar", baz: "qux" }, - }); - - const callArgs = fetchMock.mock.calls[0]; - expect(callArgs[0]).toBe("https://api.example.com/test?foo=bar&baz=qux"); - }); - - it("should throw OgiriAuthError on 401", async () => { - auth.setTokens(mockTokens); - - fetchMock.mockResolvedValue(new Response('{"error":"Unauthorized"}', { status: 401 })); - - await expect(client.request("/protected")).rejects.toThrow(OgiriAuthError); - expect(auth.isAuthenticated()).toBe(false); - }); - - it("should fire onAuthError callback on 401", async () => { - const onAuthError = vi.fn(); - auth = new OgiriAuth({ onAuthError }); - client = new OgiriFetchClient(auth, "https://api.example.com"); - auth.setTokens(mockTokens); - - fetchMock.mockResolvedValue(new Response('{"error":"Unauthorized"}', { status: 401 })); - - await expect(client.request("/protected")).rejects.toThrow(OgiriAuthError); - expect(onAuthError).toHaveBeenCalled(); - }); - - it("should throw generic error on non-401 failures", async () => { - fetchMock.mockResolvedValue(new Response("Internal Server Error", { status: 500 })); - - await expect(client.request("/test")).rejects.toThrow("HTTP 500"); - }); - }); - - describe("convenience methods", () => { - beforeEach(() => { - fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); - }); - - it("should call POST with serialized JSON body", async () => { - await client.post("/users", { name: "John" }); - const callArgs = fetchMock.mock.calls[0]; - expect(callArgs[1].method).toBe("POST"); - expect(callArgs[1].body).toBe('{"name":"John"}'); - expect(callArgs[0]).toBe("https://api.example.com/users"); - }); - }); -}); diff --git a/ogiri-client/tests/interceptors.test.ts b/ogiri-client/tests/interceptors.test.ts deleted file mode 100644 index 235d385..0000000 --- a/ogiri-client/tests/interceptors.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { injectAuth, extractTokens } from "../src/interceptors"; -import type { OgiriTokens } from "../src/types"; - -const mockTokens: OgiriTokens = { - accessToken: "test-token", - client: "test-client", - uid: "user@example.com", - expiry: "1234567890", - tokenType: "Bearer", -}; - -describe("injectAuth", () => { - it("should inject headers auth method", () => { - const config: RequestInit = {}; - const result = injectAuth(config, mockTokens, "headers"); - - expect(result.headers).toEqual({ - "access-token": "test-token", - client: "test-client", - uid: "user@example.com", - expiry: "1234567890", - "token-type": "Bearer", - }); - }); - - it("should preserve existing headers with headers auth", () => { - const config: RequestInit = { - headers: { "Content-Type": "application/json" }, - }; - const result = injectAuth(config, mockTokens, "headers"); - - expect(result.headers).toEqual({ - "Content-Type": "application/json", - "access-token": "test-token", - client: "test-client", - uid: "user@example.com", - expiry: "1234567890", - "token-type": "Bearer", - }); - }); - - it("should inject bearer auth method", () => { - const config: RequestInit = {}; - const result = injectAuth(config, mockTokens, "bearer"); - - const authHeader = (result.headers as Record)?.Authorization; - expect(authHeader).toMatch(/^Bearer /); - - const base64 = authHeader!.replace("Bearer ", ""); - const decoded = JSON.parse( - typeof Buffer !== "undefined" ? Buffer.from(base64, "base64").toString("utf-8") : atob(base64), - ); - - expect(decoded).toEqual({ - "access-token": "test-token", - client: "test-client", - uid: "user@example.com", - "token-type": "Bearer", - expiry: "1234567890", - }); - }); - - it("should inject cookies auth method", () => { - const config: RequestInit = {}; - const result = injectAuth(config, mockTokens, "cookies"); - - expect(result.credentials).toBe("include"); - const cookieHeader = (result.headers as Record)?.Cookie; - expect(cookieHeader).toContain("access-token=test-token"); - expect(cookieHeader).toContain("client=test-client"); - expect(cookieHeader).toContain("uid=user@example.com"); - expect(cookieHeader).toContain("expiry=1234567890"); - expect(cookieHeader).toContain("token-type=Bearer"); - }); - - it("should handle Headers object", () => { - const config: RequestInit = { - headers: new Headers({ "X-Custom": "value" }), - }; - const result = injectAuth(config, mockTokens, "headers"); - - const headers = result.headers as Record; - // Headers API normalizes names to lowercase - expect(headers["x-custom"]).toBe("value"); - expect(headers["access-token"]).toBe("test-token"); - }); -}); - -describe("extractTokens", () => { - it("should extract tokens from response headers", () => { - const headers = new Headers({ - "access-token": "new-token", - client: "new-client", - uid: "newuser@example.com", - expiry: "9876543210", - "token-type": "Bearer", - }); - - const response = new Response(null, { headers }); - const tokens = extractTokens(response); - - expect(tokens).toEqual({ - accessToken: "new-token", - client: "new-client", - uid: "newuser@example.com", - expiry: "9876543210", - tokenType: "Bearer", - }); - }); - - it("should return null when headers are missing", () => { - const headers = new Headers({ - "access-token": "token", - client: "client", - }); - - const response = new Response(null, { headers }); - const tokens = extractTokens(response); - - expect(tokens).toBeNull(); - }); - - it("should return null when no auth headers present", () => { - const response = new Response(null); - const tokens = extractTokens(response); - - expect(tokens).toBeNull(); - }); - - it("should handle case-insensitive headers", () => { - const headers = new Headers({ - "Access-Token": "new-token", - Client: "new-client", - Uid: "user@example.com", - Expiry: "9876543210", - "Token-Type": "Bearer", - }); - - const response = new Response(null, { headers }); - const tokens = extractTokens(response); - - expect(tokens).toEqual({ - accessToken: "new-token", - client: "new-client", - uid: "user@example.com", - expiry: "9876543210", - tokenType: "Bearer", - }); - }); -}); diff --git a/ogiri-client/tests/token-storage.test.ts b/ogiri-client/tests/token-storage.test.ts deleted file mode 100644 index 9839fc3..0000000 --- a/ogiri-client/tests/token-storage.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { MemoryTokenStorage, LocalStorageTokenStorage } from "../src/token-storage"; -import type { OgiriTokens } from "../src/types"; - -const mockTokens: OgiriTokens = { - accessToken: "test-token", - client: "test-client", - uid: "user@example.com", - expiry: "1234567890", - tokenType: "Bearer", -}; - -describe("MemoryTokenStorage", () => { - let storage: MemoryTokenStorage; - - beforeEach(() => { - storage = new MemoryTokenStorage(); - }); - - it("should return null when no tokens are stored", () => { - expect(storage.get()).toBeNull(); - }); - - it("should store and retrieve tokens", () => { - storage.set(mockTokens); - expect(storage.get()).toEqual(mockTokens); - }); - - it("should clear tokens", () => { - storage.set(mockTokens); - storage.clear(); - expect(storage.get()).toBeNull(); - }); - - it("should overwrite existing tokens", () => { - storage.set(mockTokens); - const newTokens: OgiriTokens = { ...mockTokens, accessToken: "new-token" }; - storage.set(newTokens); - expect(storage.get()).toEqual(newTokens); - }); -}); - -describe("LocalStorageTokenStorage", () => { - let storage: LocalStorageTokenStorage; - const storageKey = "test-ogiri-tokens"; - - beforeEach(() => { - vi.stubGlobal("localStorage", { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }); - storage = new LocalStorageTokenStorage(storageKey); - }); - - it("should return null when localStorage has no tokens", () => { - vi.mocked(localStorage.getItem).mockReturnValue(null); - expect(storage.get()).toBeNull(); - }); - - it("should return null when localStorage has invalid JSON", () => { - vi.mocked(localStorage.getItem).mockReturnValue("invalid-json"); - expect(storage.get()).toBeNull(); - }); - - it("should retrieve tokens from localStorage", () => { - vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockTokens)); - expect(storage.get()).toEqual(mockTokens); - expect(localStorage.getItem).toHaveBeenCalledWith(storageKey); - }); - - it("should store tokens to localStorage", () => { - storage.set(mockTokens); - expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, JSON.stringify(mockTokens)); - }); - - it("should clear tokens from localStorage", () => { - storage.clear(); - expect(localStorage.removeItem).toHaveBeenCalledWith(storageKey); - }); -}); diff --git a/ogiri-client/tsconfig.json b/ogiri-client/tsconfig.json deleted file mode 100644 index 20b6aa5..0000000 --- a/ogiri-client/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM"], - "moduleResolution": "bundler", - "strict": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] -} diff --git a/ogiri-client/tsup.config.ts b/ogiri-client/tsup.config.ts deleted file mode 100644 index cb40e12..0000000 --- a/ogiri-client/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm", "cjs"], - dts: true, - clean: true, - sourcemap: true, - minify: false, -}); diff --git a/ogiri-client/vitest.config.ts b/ogiri-client/vitest.config.ts deleted file mode 100644 index b4bf73b..0000000 --- a/ogiri-client/vitest.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov'], - exclude: [ - 'node_modules/**', - 'dist/**', - 'tests/**', - '*.config.ts', - ], - }, - }, -}); diff --git a/ogiri-core/build.gradle.kts b/ogiri-core/build.gradle.kts deleted file mode 100644 index 937c7d7..0000000 --- a/ogiri-core/build.gradle.kts +++ /dev/null @@ -1,193 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - kotlin("plugin.spring") - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - id("org.owasp.dependencycheck") version libs.versions.owasp.get() - jacoco - `java-test-fixtures` - `maven-publish` - signing -} - -allOpen { annotation("com.quantipixels.ogiri.security.core.OgiriService") } - -group = "com.quantipixels.ogiri" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - freeCompilerArgs.add("-Xjvm-default=all") - } - jvmToolchain(17) -} - -extra["jackson-bom.version"] = "2.21.5" - -extra["log4j2.version"] = "2.26.1" - -extra["tomcat.version"] = "10.1.57" - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -dependencies { - api(project(":ogiri-session-core")) - api("org.springframework.boot:spring-boot-starter-security") - api("org.springframework.boot:spring-boot-starter-web") - api("org.springframework.boot:spring-boot-starter-validation") - api("org.springframework:spring-tx") - // spring-boot-starter-data-jpa removed - users choose their own persistence - // Users must provide one of: - // - spring-boot-starter-data-jpa (for JPA/Hibernate) - // - spring-boot-starter-data-mongodb (for MongoDB) - // - Custom JDBC implementation with JdbcTemplate - // - Other persistence mechanism of choice - implementation("org.springframework.boot:spring-boot-autoconfigure") - // Optional Spring Data dependency for @NoRepositoryBean annotation - // Users who use Spring Data will have this at runtime - compileOnly("org.springframework.data:spring-data-commons") - compileOnly("org.springframework.boot:spring-boot-starter-actuator") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("io.micrometer:micrometer-core") - implementation("com.github.ben-manes.caffeine:caffeine:${libs.versions.caffeine.get()}") - - // Configuration processor for IDE autocomplete and property hints - annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") - - testImplementation(project(":ogiri-test")) - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testImplementation("org.springframework.security:spring-security-test") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - - testFixturesImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } -} - -dependencyCheck { - failBuildOnCVSS = 7.0F - suppressionFile = rootProject.file("config/dependency-check-suppressions.xml").path - failBuildOnUnusedSuppressionRule = true - scanConfigurations = listOf("runtimeClasspath") - analyzers.assemblyEnabled = false - analyzers.ossIndex.enabled = false - System.getenv("NVD_API_KEY")?.takeIf(String::isNotBlank)?.let { nvd.apiKey = it } -} - -/** - * Test configuration with JaCoCo code coverage reporting. Run tests: ./gradlew test Generate - * coverage report: ./gradlew jacocoTestReport View coverage: open - * build/reports/jacoco/test/html/index.html - */ -tasks.withType { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required = true - csv.required = false - html.required = true - html.outputLocation = layout.buildDirectory.dir("reports/jacoco/test/html") - } -} - -/** - * JaCoCo code coverage configuration. Enforces minimum 50% coverage for critical token - * functionality. - */ -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { rule { limit { minimum = "0.50".toBigDecimal() } } } -} - -tasks.named("check") { dependsOn(tasks.jacocoTestCoverageVerification) } - -// Suppress test fixtures from the published Maven POM — they are an internal test aid. -val java = components["java"] as org.gradle.api.component.AdhocComponentWithVariants - -java.withVariantsFromConfiguration(configurations["testFixturesApiElements"]) { skip() } - -java.withVariantsFromConfiguration(configurations["testFixturesRuntimeElements"]) { skip() } - -publishing { - publications { - create("mavenJava") { - from(components["java"]) - versionMapping { - usage("java-api") { fromResolutionOf("runtimeClasspath") } - usage("java-runtime") { fromResolutionResult() } - } - pom { - name.set("ogiri") - description.set( - "Spring Boot token security components with auth headers, filters, and sub-token support.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val signingKey = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val signingPassword = - (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - - if (signingKey != null && signingPassword != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - val pub = publishing.publications.findByName("mavenJava") - if (pub != null) { - sign(pub) - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriConfigurationProperties.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriConfigurationProperties.kt deleted file mode 100644 index c592cb5..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriConfigurationProperties.kt +++ /dev/null @@ -1,507 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import jakarta.annotation.PostConstruct -import jakarta.validation.Valid -import jakarta.validation.constraints.Min -import org.slf4j.LoggerFactory -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.validation.annotation.Validated - -/** - * Centralized configuration properties for ogiri security library. - * - * All ogiri configuration is prefixed with `ogiri.` in application.yml/application.properties. - * Properties are organized into three nested categories: - * - security: Filter registration and general security options - * - auth: Token behavior and rotation policies - * - cleanup: Scheduled token cleanup job configuration - * - * Example application.yml: - * ```yaml - * ogiri: - * security: - * register-filter: true - * auth: - * max-clients: 24 - * batch-grace-seconds: 5 - * token-lifespan-days: 14 - * rotate-on-write-only: false - * rotate-stale-seconds: 0 - * register-token-service: true - * cleanup: - * enabled: true - * interval-ms: 21600000 - * ``` - */ -@Validated -@ConfigurationProperties(prefix = "ogiri") -open class OgiriConfigurationProperties { - /** - * Security filter configuration. Controls filter registration and general authentication - * behavior. - */ - val security: SecurityProperties = SecurityProperties() - - /** - * Token authentication and rotation configuration. Controls token lifecycle, rotation policies, - * and grace periods. - */ - @field:Valid val auth: AuthProperties = AuthProperties() - - /** - * Scheduled token cleanup job configuration. Controls deletion of expired tokens from the - * database. - */ - val cleanup: CleanupProperties = CleanupProperties() - - /** Cookie configuration for authentication responses. */ - val cookies: CookieProperties = CookieProperties() - - /** Token comparison cache configuration. */ - val cache: CacheProperties = CacheProperties() - - /** Token entity lookup cache configuration. */ - val lookup: LookupCacheProperties = LookupCacheProperties() - - companion object { - private val logger = LoggerFactory.getLogger(OgiriConfigurationProperties::class.java) - private val KNOWN_LOOKUP_TYPES = setOf("caffeine", "redis") - } - - /** - * Validates configuration and warns about insecure default values. - * - * Called automatically by Spring after properties are bound. - */ - @PostConstruct - fun warnInsecureDefaults() { - if (auth.rotateStaleSeconds == 0L) { - logger.warn( - "ogiri.auth.rotate-stale-seconds=0 disables time-based token rotation. " + - "Consider setting to 3600 (1 hour) or higher for production security.") - } - if (!cookies.secure) { - logger.warn( - "ogiri.cookies.secure=false allows cookies over HTTP. " + - "Set to true for production deployments using HTTPS.") - } - if (!cookies.httpOnly) { - logger.warn( - "ogiri.cookies.http-only=false exposes cookies to JavaScript. " + - "Set to true to prevent XSS cookie theft.") - } - val rawLookupType = lookup.type.trim() - if (rawLookupType.isNotEmpty() && rawLookupType.lowercase() !in KNOWN_LOOKUP_TYPES) { - logger.warn( - "ogiri.lookup.type='{}' is not a recognized cache type. " + - "Expected 'caffeine' or 'redis' (lowercase). No lookup cache will be activated.", - rawLookupType) - } - } - - /** Security filter configuration properties. */ - open class SecurityProperties { - /** - * Enable automatic registration of the ogiri SecurityFilterChain bean. - * - * Set to false if you want to manually wire the filter or provide custom security configuration - * that doesn't use the auto-configuration. - * - * Default: true - */ - var registerFilter: Boolean = true - - /** CSRF configuration for the auto-registered security filter chain. */ - val csrf: CsrfProperties = CsrfProperties() - - /** - * Ant-style path patterns that bypass authentication. Uses Spring Security - * AntPathRequestMatcher. Defaults include Swagger UI, webjars, OpenAPI, actuator health/info, - * and favicon. - */ - var bypassPaths: List = - listOf( - "/swagger-ui/**", - "/swagger-ui.html", - "/webjars/**", - "/openapi/**", - "/actuator/health", - "/actuator/info", - "/favicon.ico", - ) - - /** - * CSRF mode for the ogiri auto-registered security chain. - * - * Supported values: - * - "auto": Enable CSRF only when cookies are enabled and SameSite=None. - * - "true": Always enable CSRF. - * - "false": Always disable CSRF. - * - * Default: "auto" - */ - open class CsrfProperties { - var enabled: String = "auto" - } - } - - /** - * Token authentication and rotation configuration properties. - * - * These properties control token issuance, validation, and rotation behavior. All settings are - * sourced from application configuration and NOT from TokenService constructor arguments. - */ - open class AuthProperties { - /** - * Enable auto-registration of the default - * [com.quantipixels.ogiri.security.tokens.OgiriTokenService]. - * - * Set to false if your application provides its own - * [com.quantipixels.ogiri.security.tokens.OgiriTokenService] (or you want to fully customize - * how token services are wired). - * - * Default: true - */ - var registerTokenService: Boolean = true - - /** - * Maximum number of active APP tokens per user. - * - * When a new APP token is created and this limit is reached, the oldest token is revoked to - * maintain the limit. This prevents token accumulation and provides a mechanism for limiting - * concurrent sessions. - * - * Default: 24 Valid Range: 1 - no upper limit - * - * Examples: - * - High-security: 5 (only 5 concurrent sessions) - * - Default: 10 (reasonable for multi-device users) - * - Relaxed: 50 (for testing or high-concurrency scenarios) - */ - @field:Min(1) var maxClients: Long = 10 - - /** - * Grace period (seconds) for detecting batch requests within a request window. - * - * When multiple requests arrive within this window, the TokenService only updates the - * lastUsedAt timestamp without issuing a new token. This prevents "token thrashing" from rapid - * consecutive requests (e.g., simultaneous API calls from the same client or parallel - * image/resource loads). - * - * Requests outside this window trigger token rotation and emit new auth headers. - * - * Default: 5 Valid Range: 0 - any positive integer - * - * Examples: - * - Conservative: 1 second (rotate frequently) - * - Default: 5 seconds (balance between rotation and efficiency) - * - Relaxed: 30 seconds (for high-traffic scenarios) - * - Development: 60 seconds (minimize rotation during testing) - * - * See [OgiriTokenAuthenticationFilter.doFilterInternal] for batch window logic. - */ - @field:Min(0) var batchGraceSeconds: Long = 5 - - /** - * Default token lifetime in days. - * - * New tokens created by TokenService.createNewAuthToken() will expire after this duration. This - * applies to APP tokens only; sub-tokens can override this by implementing custom expiry logic - * in OgiriSubTokenRegistration.expiry(). - * - * Default: 14 Valid Range: 1 - any positive integer - * - * Examples: - * - Short-lived: 7 days (high security, frequent re-auth) - * - Default: 14 days (balance between security and user experience) - * - Long-lived: 30 days (minimal re-auth, lower security) - * - * Set via application.yml: - * ```yaml - * ogiri: - * auth: - * token-lifespan-days: 7 - * ``` - */ - @field:Min(1) var tokenLifespanDays: Long = 14 - - /** - * Only rotate tokens on mutating HTTP requests (POST, PUT, DELETE, PATCH). - * - * When true, token rotation is triggered only for requests that modify server state. GET - * requests update lastUsedAt but do NOT trigger rotation, reducing rotation overhead for - * read-heavy workloads. - * - * Default: false (rotate on all requests) Valid Values: true, false - * - * Use Cases: - * - Read-heavy APIs: Set to true to reduce rotation overhead - * - Standard APIs: Keep false for consistent token rotation - * - * See [OgiriTokenAuthenticationFilter.rotateTokensIfNeeded] for implementation. - */ - var rotateOnWriteOnly: Boolean = false - - /** - * Force token rotation if token exceeds this age (seconds). - * - * Regardless of request batching, if a token has been in use for longer than this duration, it - * will be rotated on the next request. This provides a safeguard against indefinitely - * long-lived tokens and ensures periodic credential refresh. - * - * Default: 3600 (1 hour, secure by default) Valid Range: 0 (disabled) or any positive integer - * (seconds) - * - * Examples: - * - Disabled: 0 (rely only on batch window logic) - * - Hourly: 3600 (force rotation every hour) - Default - * - Daily: 86400 (force rotation every day) - * - * See [OgiriTokenAuthenticationFilter.rotateTokensIfNeeded] for implementation. - */ - var rotateStaleSeconds: Long = 3600 - - /** - * Maximum allowed size for bearer tokens in bytes. - * - * This limit prevents memory exhaustion attacks where an attacker sends extremely large bearer - * tokens that would be base64 decoded and parsed. The default of 8KB is sufficient for normal - * JWT tokens (typically 1-2KB) while blocking potential DoS attempts. - * - * Default: 8192 (8KB) Valid Range: 256 - any positive integer - * - * Examples: - * - Minimal: 1024 (1KB, for very small tokens) - * - Default: 8192 (8KB, standard JWT size) - * - Large: 16384 (16KB, for tokens with extensive claims) - * - * See [com.quantipixels.ogiri.security.core.AuthHeader.parseBearerToken] for implementation. - */ - @field:Min(256) var maxBearerTokenSize: Int = 8192 - } - - /** - * Scheduled token cleanup job configuration. - * - * The [OgiriTokenCleanupJob] runs periodically to delete expired tokens from the database, - * preventing accumulation of stale data. - */ - open class CleanupProperties { - /** - * Enable the scheduled OgiriTokenCleanupJob. - * - * When true, the cleanup job runs at a fixed interval and deletes all tokens where expiryAt < - * now(). When false, the cleanup job is not registered or executed. - * - * Default: true Valid Values: true, false - * - * Use Cases: - * - Production: true (automatic cleanup) - * - Testing: false (preserve test tokens for inspection) - * - Custom cleanup: false (implement your own cleanup logic) - */ - var enabled: Boolean = true - - /** - * Interval in milliseconds between cleanup job executions. - * - * The cleanup job uses a fixed delay, meaning the next execution starts after the specified - * interval has elapsed since the previous execution completed. - * - * Default: 21600000 (6 hours) - * - * Examples: - * - 21600000 (6 hours) - default, good for most production use cases - * - 3600000 (1 hour) - for higher-traffic applications - * - 43200000 (12 hours) - for lower-traffic applications - * - * Only used if [enabled] is true. - */ - var intervalMs: Long = 21600000 - - /** - * Batch size for token cleanup operations. - * - * When cleaning up expired tokens, tokens are deleted in batches of this size to avoid - * overwhelming the database with large DELETE operations. - * - * Default: 1000 Valid Range: 100 - any positive integer - * - * Examples: - * - 100: Conservative, for databases with limited resources - * - 1000: Default, good balance for most deployments - * - 5000: Aggressive, for high-performance databases - */ - @field:Min(100) var batchSize: Int = 1000 - } - - /** - * Cookie configuration properties for authentication responses. - * - * Controls how authentication cookies are set in HTTP responses. By default, cookies are enabled - * with secure settings (HttpOnly, Secure, SameSite=Strict) to prevent XSS and CSRF attacks. - */ - open class CookieProperties { - /** - * Enable setting authentication cookies in responses. - * - * When true, authentication headers will also be set as secure cookies. When false, only HTTP - * headers are set. - * - * Default: true - */ - var enabled: Boolean = true - - /** - * Set the Secure flag on cookies. - * - * When true, cookies are only sent over HTTPS connections. Should always be true in production. - * - * Default: true - */ - var secure: Boolean = true - - /** - * Set the HttpOnly flag on cookies. - * - * When true, cookies are not accessible via JavaScript, preventing XSS cookie theft. - * - * Default: true - */ - var httpOnly: Boolean = true - - /** - * SameSite attribute for cookies. - * - * Controls when cookies are sent with cross-site requests: - * - "Strict": Only sent with same-site requests (most secure) - * - "Lax": Sent with same-site and top-level navigation (reasonable default) - * - "None": Sent with all requests (requires Secure=true) - * - * Default: "Strict" - */ - var sameSite: String = "Strict" - - /** - * Cookie path attribute. - * - * Default: "/" (entire site) - */ - var path: String = "/" - } - - /** - * Token entity lookup cache configuration. - * - * Controls the optional token lookup cache added by `ogiri-caffeine` or `ogiri-redis`. No cache - * is active unless `ogiri.lookup.type` is explicitly set. - * - * Example (application.yml): - * ```yaml - * ogiri: - * lookup: - * type: caffeine # or redis - * max-size: 10000 - * expiry-minutes: 5 - * ``` - */ - open class LookupCacheProperties { - /** - * Cache backend to activate. - * - * Supported values: - * - `caffeine` — in-process Caffeine cache (single-instance deployments) - * - `redis` — shared Redis cache (multi-instance deployments) - * - (absent) — no lookup cache; every request hits the database - * - * The corresponding module (`ogiri-caffeine` or `ogiri-redis`) must be on the classpath. - */ - var type: String = "" - - /** - * Maximum number of token entities to hold in the cache. - * - * Applies to Caffeine. Ignored by Redis (which relies on TTL-based eviction only). - * - * Default: 10000 - */ - @field:Min(100) var maxSize: Long = 10_000 - - /** - * Time in minutes before a cached token entity expires. - * - * Applies to both Caffeine and Redis. A shorter TTL (e.g. 5 minutes) is recommended for token - * entities because revocation must propagate quickly. - * - * Default: 5 - */ - @field:Min(1) var expiryMinutes: Long = 5 - } - - /** - * Token comparison cache configuration properties. - * - * The token service uses a Caffeine cache to avoid repeated BCrypt comparisons for the same - * token. This cache stores the result of token hash comparisons for a configurable duration. - */ - open class CacheProperties { - /** - * Maximum number of token comparison results to cache. - * - * Higher values use more memory but reduce BCrypt computation for high-traffic applications. - * - * Default: 10000 - */ - @field:Min(100) var maxSize: Long = 10000 - - /** - * Time in minutes before cached token comparison results expire. - * - * Lower values improve security (invalidate cached results sooner) but increase BCrypt load. - * Higher values reduce CPU usage but may allow stale cache entries. - * - * Default: 60 (1 hour) - */ - @field:Min(1) var expiryMinutes: Long = 60 - - /** - * Enable the Spring [org.springframework.cache.CacheManager] bridge for token entity lookups. - * - * When `true`, ogiri wraps the existing `CacheManager` bean as an [OgiriTokenLookupCache]. - * Consumers configure their cache provider (Redis, Ehcache, Hazelcast) entirely through - * standard Spring Boot properties — no ogiri-specific dependency is required. - * - * Requires a `CacheManager` bean to be present in the application context (e.g., via - * `spring.cache.type=redis`). - * - * **Note:** [org.springframework.cache.Cache]-level eviction does not support pattern-based - * user-wide eviction. `evictAll(userId)` is a no-op on this tier; stale entries expire via the - * configured TTL. For immediate user-wide eviction, provide a custom [OgiriTokenLookupCache] - * bean (Tier 1). - * - * Default: false - */ - var useSpringCacheManager: Boolean = false - - /** - * Name of the Spring cache to use when [useSpringCacheManager] is `true`. - * - * Must match one of the names declared in `spring.cache.cache-names`. - * - * Default: "ogiri-token-lookup" - */ - var cacheName: String = "ogiri-token-lookup" - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriLookupTypeCondition.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriLookupTypeCondition.kt deleted file mode 100644 index 319907e..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriLookupTypeCondition.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import org.springframework.boot.autoconfigure.condition.ConditionMessage -import org.springframework.boot.autoconfigure.condition.ConditionOutcome -import org.springframework.boot.autoconfigure.condition.SpringBootCondition -import org.springframework.context.annotation.ConditionContext -import org.springframework.core.type.AnnotatedTypeMetadata - -/** - * Shared [SpringBootCondition] that matches when `ogiri.lookup.type` equals [type]. - * - * Extend with a no-arg subclass to use with `@Conditional`: - * ```kotlin - * internal class OnCaffeineType : OgiriLookupTypeCondition("caffeine") - * ``` - */ -open class OgiriLookupTypeCondition(private val type: String) : SpringBootCondition() { - override fun getMatchOutcome( - context: ConditionContext, - metadata: AnnotatedTypeMetadata, - ): ConditionOutcome { - val normalized = context.environment.getProperty("ogiri.lookup.type")?.trim()?.lowercase() ?: "" - return if (normalized == type) { - ConditionOutcome( - true, - ConditionMessage.forCondition("OgiriLookupType[$type]") - .found("property") - .items("ogiri.lookup.type=$type"), - ) - } else { - ConditionOutcome( - false, - ConditionMessage.forCondition("OgiriLookupType[$type]") - .didNotFind("property with value '$type'") - .items("ogiri.lookup.type"), - ) - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzer.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzer.kt deleted file mode 100644 index 1da0124..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzer.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import org.springframework.beans.factory.NoSuchBeanDefinitionException -import org.springframework.boot.diagnostics.AbstractFailureAnalyzer -import org.springframework.boot.diagnostics.FailureAnalysis - -/** - * Failure analyzer that provides helpful error messages when required Ogiri beans are missing. - * - * Instead of cryptic Spring injection failures, users see actionable guidance on how to configure - * the missing beans. - */ -class OgiriMissingBeanFailureAnalyzer : AbstractFailureAnalyzer() { - - override fun analyze( - rootFailure: Throwable, - cause: NoSuchBeanDefinitionException, - ): FailureAnalysis? { - val beanType = cause.beanType?.simpleName?.takeIf { it.isNotBlank() } ?: return null - - return when { - beanType.contains("OgiriTokenRepository") -> - FailureAnalysis( - "No OgiriTokenRepository bean found.", - """ - |Ogiri requires an OgiriTokenRepository bean for token persistence. - | - |Option 1 - Use ogiri-jpa (recommended for JPA/Hibernate): - | Add dependency: - | implementation("com.quantipixels.ogiri:ogiri-jpa:VERSION") - | - | Then create your token entity and repository: - | @Entity - | class MyToken : OgiriBaseTokenEntity() - | - | @Repository - | interface MyTokenRepository : - | JpaRepository, - | OgiriTokenRepository - | - | Spring Data automatically generates all query implementations. - | - |Option 2 - Implement manually: - | @Repository - | class MyTokenRepository : OgiriTokenRepository { - | override fun save(token: MyToken): MyToken = ... - | override fun findById(id: Long): MyToken? = ... - | // ... implement all required methods - | } - | - |Documentation: https://quantipixels.github.io/ogiri/database/ - """ - .trimMargin(), - cause, - ) - beanType.contains("OgiriUserDirectory") -> - FailureAnalysis( - "No OgiriUserDirectory bean found.", - """ - |Ogiri requires an OgiriUserDirectory bean to resolve users. - | - |Create a component that implements OgiriUserDirectory: - | - | @Component - | class MyUserDirectory(private val userService: UserService) : OgiriUserDirectory { - | - | override fun findById(id: Long): OgiriUser? = - | userService.findById(id) - | - | override fun findByUsername(username: String): OgiriUser? = - | userService.findByUsername(username) - | - | override fun findByEmail(email: String): OgiriUser? = - | userService.findByEmail(email) - | - | override fun loadUserByUsername(username: String): OgiriUser = - | findByUsername(username) - | ?: throw UsernameNotFoundException("User not found: ${'$'}username") - | - | override fun recordSuccessfulLogin(userId: Long) { - | userService.updateLastLogin(userId) - | } - | } - | - |Your user entity must implement OgiriUser: - | - | class User : OgiriUser { - | override fun getOgiriUserId(): Long = id - | // ... UserDetails methods - | } - | - |Documentation: https://quantipixels.github.io/ogiri/quickstart/ - """ - .trimMargin(), - cause, - ) - beanType.contains("OgiriRouteRegistry") -> - FailureAnalysis( - "No OgiriRouteRegistry bean found.", - """ - |Ogiri requires at least one OgiriRouteRegistry bean to define public routes. - | - |Create a component that implements OgiriRouteRegistry: - | - | @Component - | class MyRouteRegistry : OgiriRouteRegistry { - | override fun routes(): List = listOf( - | OgiriRoute.post("/api/auth/login"), - | OgiriRoute.post("/api/auth/register"), - | OgiriRoute.get("/api/health"), - | OgiriRoute.get("/api/public/**"), - | ) - | } - | - |Routes defined here bypass authentication. - | - |Documentation: https://quantipixels.github.io/ogiri/configuration/ - """ - .trimMargin(), - cause, - ) - else -> null - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfiguration.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfiguration.kt deleted file mode 100644 index d4b879c..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfiguration.kt +++ /dev/null @@ -1,384 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import com.quantipixels.ogiri.security.core.DefaultIdentifierPolicy -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.helpers.AuthenticationBypassDecider -import com.quantipixels.ogiri.security.routes.OgiriRouteCatalog -import com.quantipixels.ogiri.security.routes.OgiriRouteRegistry -import com.quantipixels.ogiri.security.spi.OgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriSpringCacheAdapter -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.tokens.DefaultOgiriSubTokenRegistry -import com.quantipixels.ogiri.security.tokens.DefaultOgiriTokenServiceResolver -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistration -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry -import com.quantipixels.ogiri.security.tokens.OgiriToken -import com.quantipixels.ogiri.security.tokens.OgiriTokenCleanupJob -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import com.quantipixels.ogiri.security.tokens.OgiriTokenServiceResolver -import com.quantipixels.ogiri.security.web.OgiriAuthenticationEntryPoint -import com.quantipixels.ogiri.security.web.OgiriTokenAuthenticationFilter -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.ObjectProvider -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.cache.CacheManager -import org.springframework.context.MessageSource -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.security.web.AuthenticationEntryPoint -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -import org.springframework.security.web.csrf.CookieCsrfTokenRepository -import org.springframework.security.web.util.matcher.AntPathRequestMatcher -import org.springframework.security.web.util.matcher.OrRequestMatcher -import org.springframework.security.web.util.matcher.RequestMatcher - -/** - * Auto-configures ogiri beans (token service, filters, registries). - * - * This configuration class: - * - Enables the OgiriConfigurationProperties for property binding - * - Creates core ogiri beans (TokenService, TokenAuthenticationFilter, etc.) - * - Registers the security filter chain when enabled via properties - * - Conditionally registers OgiriTokenCleanupJob for expired token deletion - * - * Configuration is loaded from application.yml/application.properties with prefix "ogiri". All - * beans are registered with @ConditionalOnMissingBean for easy override. - */ -@Configuration -@EnableConfigurationProperties(OgiriConfigurationProperties::class) -class OgiriSecurityAutoConfiguration { - companion object { - private val logger = LoggerFactory.getLogger(OgiriSecurityAutoConfiguration::class.java) - } - - /** - * Auto-configures BCryptPasswordEncoder when no PasswordEncoder bean is present. - * - * @return a BCryptPasswordEncoder instance - */ - @Bean - @ConditionalOnMissingBean(PasswordEncoder::class) - fun ogiriPasswordEncoder(): PasswordEncoder { - logger.info("No PasswordEncoder bean found, auto-configuring BCryptPasswordEncoder") - return BCryptPasswordEncoder() - } - - /** - * Provides a default identifier policy used by the token system. - * - * This supplies a DefaultIdentifierPolicy instance when no other IdentifierPolicy bean is - * present. - * - * @return a new DefaultIdentifierPolicy instance - */ - @Bean - @ConditionalOnMissingBean(IdentifierPolicy::class) - fun identifierPolicy(): DefaultIdentifierPolicy = DefaultIdentifierPolicy() - - /** - * Creates an OgiriRouteCatalog by aggregating all available OgiriRouteRegistry beans in order. - * - * @param registries Provider of OgiriRouteRegistry instances; collected in the provider's - * declared order. - * @return An OgiriRouteCatalog containing the ordered list of route registries. - */ - @Bean - @ConditionalOnMissingBean(OgiriRouteCatalog::class) - fun routeCatalog(registries: ObjectProvider): OgiriRouteCatalog = - OgiriRouteCatalog(registries.orderedStream().toList()) - - /** - * Creates an AuthenticationBypassDecider that determines which routes should bypass - * authentication using the provided route catalog and bypass paths from configuration. - * - * @param routeCatalog The catalog of registered routes used to evaluate bypass rules. - * @param properties Ogiri configuration properties containing bypass paths. - * @return An AuthenticationBypassDecider configured with the route catalog and bypass matcher. - */ - @Bean - @ConditionalOnMissingBean(AuthenticationBypassDecider::class) - fun authenticationBypassDecider( - routeCatalog: OgiriRouteCatalog, - properties: OgiriConfigurationProperties, - ): AuthenticationBypassDecider { - val matchers = properties.security.bypassPaths.map { AntPathRequestMatcher(it) } - val bypassMatcher: RequestMatcher = - if (matchers.isEmpty()) RequestMatcher { false } else OrRequestMatcher(matchers.toList()) - return AuthenticationBypassDecider(routeCatalog, bypassMatcher) - } - - /** - * Creates a DefaultOgiriSubTokenRegistry initialized with the available sub-token registrations. - * - * @param registrations provider of OgiriSubTokenRegistration instances; the provider's ordered - * stream is used to preserve registration order - * @return a DefaultOgiriSubTokenRegistry containing the registrations provided by `registrations` - */ - @Bean - @ConditionalOnMissingBean(OgiriSubTokenRegistry::class) - fun subTokenRegistry( - registrations: ObjectProvider - ): DefaultOgiriSubTokenRegistry = - DefaultOgiriSubTokenRegistry(registrations.orderedStream().toList()) - - /** - * Tier 2 cache: bridges an existing Spring [CacheManager] into the [OgiriTokenLookupCache] SPI. - * - * Activated when: - * - No custom [OgiriTokenLookupCache] bean is present (consumer Tier 1 takes precedence) - * - A [CacheManager] bean is available (e.g., `spring.cache.type=redis`) - * - `ogiri.cache.use-spring-cache-manager=true` - * - * @see OgiriSpringCacheAdapter for evictAll limitations on this tier. - */ - @Bean - @ConditionalOnMissingBean(OgiriTokenLookupCache::class) - @ConditionalOnBean(CacheManager::class) - @ConditionalOnProperty( - prefix = "ogiri.cache", - name = ["use-spring-cache-manager"], - havingValue = "true", - ) - fun ogiriSpringCacheAdapter( - cacheManager: CacheManager, - properties: OgiriConfigurationProperties, - ): OgiriTokenLookupCache = OgiriSpringCacheAdapter(cacheManager, properties.cache.cacheName) - - /** - * Creates a default OgiriTokenService configured with the provided collaborators for token - * management. Optional extension points ([OgiriAuditHook], [OgiriRateLimitHook], - * [OgiriTokenLookupCache]) are resolved via [ObjectProvider] and wired via setter injection after - * construction, following the Spring Security [AuthorizationFilter] pattern. - * - * @param repository Repository used to persist and retrieve tokens. - * @param passwordEncoder Encoder used to hash or verify token secrets. - * @param ogiriUserDirectory Directory used to resolve and validate users associated with tokens. - * @param identifierPolicy Policy responsible for generating token identifiers. - * @param subTokenRegistry Registry of sub-token registrations used by the service. - * @param properties Ogiri configuration properties influencing token behavior. - * @param auditHook Optional audit hook; resolved from context when present and wired via setter. - * @param rateLimitHook Optional rate-limit hook; resolved from context when present and wired via - * setter. - * @param lookupCache Optional token lookup cache; resolved from context when present and wired - * via setter. - * @return An instance of OgiriTokenService configured with the given collaborators. - */ - @Bean - @ConditionalOnMissingBean(OgiriTokenService::class) - @ConditionalOnProperty( - prefix = "ogiri.auth", - name = ["register-token-service"], - havingValue = "true", - matchIfMissing = true, - ) - fun ogiriTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - ogiriUserDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - auditHook: ObjectProvider, - rateLimitHook: ObjectProvider, - lookupCache: ObjectProvider>, - ): OgiriTokenService { - val service = - OgiriTokenService( - repository, - passwordEncoder, - ogiriUserDirectory, - identifierPolicy, - subTokenRegistry, - properties, - ) - auditHook.ifAvailable { service.setAuditHook(it) } - rateLimitHook.ifAvailable { service.setRateLimitHook(it) } - lookupCache.ifAvailable { service.setLookupCache(it) } - return service - } - - /** - * Creates a resolver that selects and exposes available OgiriTokenService instances. - * - * @param tokenServices Map of all registered `OgiriTokenService` beans keyed by bean name. - * @param properties Ogiri configuration properties used to influence resolver behavior. - * @param beanFactory Spring bean factory used for any runtime bean resolution required by the - * resolver. - * @return A `DefaultOgiriTokenServiceResolver` that resolves the appropriate `OgiriTokenService` - * implementations. - */ - @Bean - @ConditionalOnMissingBean(OgiriTokenServiceResolver::class) - fun ogiriTokenServiceResolver( - tokenServices: Map>, - properties: OgiriConfigurationProperties, - beanFactory: ConfigurableListableBeanFactory, - ): DefaultOgiriTokenServiceResolver = - DefaultOgiriTokenServiceResolver(tokenServices, properties, beanFactory) - - /** - * Creates an OgiriAuthenticationEntryPoint that produces localized authentication failure - * responses. - * - * When cookies are enabled, the entry point clears authentication cookies on 401 responses to - * prevent clients from being stuck in a 401 loop with stale credentials. - * - * @param messageSource source of localized messages used by the entry point - * @param properties Ogiri configuration properties containing cookie settings - * @return an OgiriAuthenticationEntryPoint configured with the given MessageSource and properties - */ - @Bean - @ConditionalOnMissingBean(OgiriAuthenticationEntryPoint::class) - fun ogiriAuthenticationEntryPoint( - messageSource: MessageSource, - properties: OgiriConfigurationProperties, - ): OgiriAuthenticationEntryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - - /** - * Creates an OgiriTokenAuthenticationFilter configured with the resolved token service and - * provided collaborators. - * - * @param ogiriUserDirectory Directory used to resolve users from tokens. - * @param tokenServiceResolver Resolver used to obtain the active OgiriTokenService instance. - * @param authenticationEntryPoint Entry point invoked on authentication failures. - * @param authenticationBypassDecider Decider that determines whether authentication should be - * bypassed for a request. - * @param identifierPolicy Policy used to extract and validate identifiers from requests/tokens. - * @param properties Ogiri configuration properties influencing filter behavior. - * @return A configured OgiriTokenAuthenticationFilter instance. - */ - @Bean - @ConditionalOnMissingBean(OgiriTokenAuthenticationFilter::class) - fun ogiriTokenAuthenticationFilter( - ogiriUserDirectory: OgiriUserDirectory, - tokenServiceResolver: OgiriTokenServiceResolver, - @Qualifier("ogiriAuthenticationEntryPoint") - authenticationEntryPoint: AuthenticationEntryPoint, - authenticationBypassDecider: AuthenticationBypassDecider, - identifierPolicy: IdentifierPolicy, - properties: OgiriConfigurationProperties, - ): OgiriTokenAuthenticationFilter = - OgiriTokenAuthenticationFilter( - ogiriUserDirectory, - tokenServiceResolver.resolve(), - authenticationEntryPoint, - authenticationBypassDecider, - identifierPolicy, - properties, - ) - - /** - * Creates a periodic job that cleans up expired Ogiri tokens using the provided token service - * resolver. - * - * @param tokenServiceResolver Resolver used to locate available token services for cleanup. - * @return The configured [OgiriTokenCleanupJob] responsible for expiring and removing tokens. - */ - @Bean - @ConditionalOnMissingBean(OgiriTokenCleanupJob::class) - @ConditionalOnProperty( - prefix = "ogiri.cleanup", - name = ["enabled"], - havingValue = "true", - matchIfMissing = true, - ) - fun ogiriTokenCleanupJob(tokenServiceResolver: OgiriTokenServiceResolver): OgiriTokenCleanupJob = - OgiriTokenCleanupJob(tokenServiceResolver) - - /** - * Configures a stateless SecurityFilterChain that applies Ogiri token authentication. - * - * This chain configures: - * - CSRF (disabled by default; enabled automatically when `ogiri.cookies.same-site=None`) - * - Stateless session management - * - Authentication failure delegation to the provided entry point - * - Ogiri token authentication filter inserted before UsernamePasswordAuthenticationFilter - * - * **This bean does NOT call `authorizeHttpRequests()`.** Ogiri handles authentication (verifying - * who the caller is) but not authorization (which routes they may access). Without an - * `authorizeHttpRequests()` call, Spring Security's default behaviour applies — all routes are - * open to unauthenticated requests. Consumers MUST define their own `SecurityFilterChain` bean - * with `authorizeHttpRequests()` rules to restrict access. - * - * @return A SecurityFilterChain that enforces token-based authentication, configures CSRF and - * stateless sessions, and delegates authentication failures to the provided - * AuthenticationEntryPoint. Route authorization rules are not configured. - */ - @Bean(name = ["ogiriSecurityFilterChain"]) - @ConditionalOnMissingBean(name = ["ogiriSecurityFilterChain"]) - @ConditionalOnProperty( - prefix = "ogiri.security", - name = ["register-filter"], - havingValue = "true", - matchIfMissing = true, - ) - @ConditionalOnWebApplication - fun ogiriSecurityFilterChain( - http: HttpSecurity, - ogiriTokenAuthenticationFilter: OgiriTokenAuthenticationFilter, - @Qualifier("ogiriAuthenticationEntryPoint") - authenticationEntryPoint: AuthenticationEntryPoint, - properties: OgiriConfigurationProperties, - ): SecurityFilterChain = - // In auto mode, only enable CSRF when cross-site cookies are in play. - http - .csrf { csrf -> - if (shouldEnableCsrf(properties)) { - csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - } else { - csrf.disable() - } - } - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .exceptionHandling { it.authenticationEntryPoint(authenticationEntryPoint) } - .addFilterBefore( - ogiriTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) - // NOTE: Callers must configure their own authorizeHttpRequests() rules if needed. - // By default, this chain only configures token-based authentication and stateless - // sessions. - .build() - - private fun shouldEnableCsrf(properties: OgiriConfigurationProperties): Boolean { - val configured = properties.security.csrf.enabled.trim().lowercase() - val autoEnabled = - properties.cookies.enabled && properties.cookies.sameSite.equals("None", ignoreCase = true) - - return when (configured) { - "true" -> true - "false" -> false - "auto" -> autoEnabled - else -> { - logger.warn( - "Invalid ogiri.security.csrf.enabled='{}' (expected auto|true|false). Falling back to auto.", - properties.security.csrf.enabled) - autoEnabled - } - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/AuthHeader.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/AuthHeader.kt deleted file mode 100644 index 2ecbb91..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/AuthHeader.kt +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -import com.fasterxml.jackson.core.JsonProcessingException -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import java.io.IOException -import java.util.Base64 -import org.slf4j.LoggerFactory - -private val logger = LoggerFactory.getLogger(AuthHeader::class.java) - -const val ACCESS_TOKEN = "access-token" -const val CLIENT = "client" -const val UID = "uid" -const val TOKEN_TYPE = "token-type" -const val EXPIRY = "expiry" -const val ACCESS_TOKEN_KIND = "access-token-kind" - -/** - * Default maximum allowed size for bearer tokens in bytes. - * - * This limit prevents memory exhaustion attacks where an attacker sends extremely large bearer - * tokens that would be base64 decoded and parsed. 8KB is sufficient for normal JWT tokens while - * blocking potential DoS attempts. - * - * This constant is used as a fallback default. Applications should configure the limit via: - * ```yaml - * ogiri: - * auth: - * max-bearer-token-size: 8192 - * ``` - */ -const val DEFAULT_MAX_BEARER_TOKEN_SIZE = 8192 - -private val mapper = JsonCodec.mapper - -data class AuthHeader( - var accessToken: String? = null, - var client: String? = null, - var uid: String? = null, - var expiry: String? = null, - var kind: String? = null, - var subTokens: Map? = null, -) { - /** - * Indicates whether the auth header contains non-blank accessToken, client, uid, and expiry. - * - * @return `true` if accessToken, client, uid, and expiry are all present and not blank, `false` - * otherwise. - */ - fun isValid(): Boolean = - !accessToken.isNullOrBlank() && - !client.isNullOrBlank() && - !uid.isNullOrBlank() && - !expiry.isNullOrBlank() -} - -/** Sub-token header payload for arbitrary token types (see [OgiriSubTokenRegistration]). */ -data class SubTokenHeader( - val client: String? = null, - val token: String? = null, - val expiry: String? = null, -) - -/** - * Get the value of the cookie with the given name. - * - * @param name The cookie name to look up. - * @return The cookie value, or `null` if no cookie with that name exists. - */ -private fun HttpServletRequest.cookieValue(name: String): String? = - cookies?.firstOrNull { it.name == name }?.value - -/** - * Extracts authentication values from the HTTP request, preferring explicit headers and falling - * back to cookies. - * - * Reads the ACCESS_TOKEN header first; if present, returns an AuthHeader populated from the - * ACCESS_TOKEN, CLIENT, UID, EXPIRY and ACCESS_TOKEN_KIND request headers. If the ACCESS_TOKEN - * header is missing or blank, returns an AuthHeader populated from the ACCESS_TOKEN, CLIENT, UID - * and EXPIRY cookies and the ACCESS_TOKEN_KIND header. - * - * @return An AuthHeader containing the extracted access token, client, uid, expiry, kind, and any - * defaulted subTokens. - */ -fun HttpServletRequest.extractAuthHeader(): AuthHeader { - var accessToken = getHeader(ACCESS_TOKEN) - if (!accessToken.isNullOrBlank()) { - val client = getHeader(CLIENT) - val uid = getHeader(UID) - val expiry = getHeader(EXPIRY) - val kind = getHeader(ACCESS_TOKEN_KIND) - return AuthHeader(accessToken, client, uid, expiry, kind) - } - accessToken = cookieValue(ACCESS_TOKEN) - val client = cookieValue(CLIENT) - val uid = cookieValue(UID) - val expiry = cookieValue(EXPIRY) - val kind = getHeader(ACCESS_TOKEN_KIND) - return AuthHeader(accessToken, client, uid, expiry, kind) -} - -/** - * Adds authentication headers to this response based on the provided AuthHeader. - * - * If `authHeaders` is null the response is left unchanged. For non-null input this sets standard - * headers (access token, client, uid, token type "Bearer", token kind, expiry) when their values - * are present. For each sub-token entry it emits two headers: the sub-token key with the sub-token - * value, and `-authorization` containing a Base64-encoded JSON payload with `client`, - * `token`, and `expiry`. If the main access token is present it also sets the `Authorization` - * header to `Bearer ` followed by a Base64-encoded JSON payload containing `access_token`, - * `client`, `uid`, `token_type`, and `expiry`. - * - * @param authHeaders Authentication header data; when null no headers are added. - * @param cookieConfig Optional cookie configuration; when provided, secure cookies are also set. - */ -fun HttpServletResponse.appendAuthHeaders( - authHeaders: AuthHeader?, - cookieConfig: OgiriConfigurationProperties.CookieProperties? = null, -) { - if (authHeaders == null) return - - fun setIfNotBlank( - name: String, - value: String?, - ) { - if (!value.isNullOrBlank()) { - setHeader(name, value) - } - } - - setIfNotBlank(ACCESS_TOKEN, authHeaders.accessToken) - setIfNotBlank(CLIENT, authHeaders.client) - setIfNotBlank(UID, authHeaders.uid) - setIfNotBlank(TOKEN_TYPE, "Bearer") - setIfNotBlank(ACCESS_TOKEN_KIND, authHeaders.kind) - setIfNotBlank(EXPIRY, authHeaders.expiry) - - authHeaders.subTokens - ?.takeIf { it.isNotEmpty() } - ?.let { subs -> - subs.forEach { (key, token) -> - val payload = - mapOf( - "client" to token.client, - "token" to token.token, - "expiry" to token.expiry, - ) - val json = mapper.writeValueAsString(payload) - val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - setHeader(key, token.token) - setHeader("$key-authorization", encoded) - } - } - - if (!authHeaders.accessToken.isNullOrBlank()) { - val payload = - mapOf( - ACCESS_TOKEN to authHeaders.accessToken, - CLIENT to authHeaders.client, - UID to authHeaders.uid, - TOKEN_TYPE to "Bearer", - EXPIRY to authHeaders.expiry, - ) - val json = mapper.writeValueAsString(payload) - val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - setHeader("Authorization", "Bearer $encoded") - } - - if (cookieConfig?.enabled == true) { - appendAuthCookies(authHeaders, cookieConfig) - } -} - -/** - * Sets secure authentication cookies based on the provided AuthHeader and cookie configuration. - * - * Creates cookies with security attributes (HttpOnly, Secure, SameSite) to prevent XSS and CSRF - * attacks. Each cookie is configured according to the provided [cookieConfig]. - * - * @param authHeaders Authentication header data containing token values. - * @param cookieConfig Cookie configuration specifying security attributes. - */ -fun HttpServletResponse.appendAuthCookies( - authHeaders: AuthHeader, - cookieConfig: OgiriConfigurationProperties.CookieProperties, -) { - fun addSecureCookie( - name: String, - value: String?, - ) { - if (value.isNullOrBlank()) return - val cookie = - Cookie(name, value).apply { - isHttpOnly = cookieConfig.httpOnly - secure = cookieConfig.secure - path = cookieConfig.path - // SameSite is set via setAttribute (Servlet 6.0+) - setAttribute("SameSite", cookieConfig.sameSite) - } - addCookie(cookie) - } - - addSecureCookie(ACCESS_TOKEN, authHeaders.accessToken) - addSecureCookie(CLIENT, authHeaders.client) - addSecureCookie(UID, authHeaders.uid) - addSecureCookie(EXPIRY, authHeaders.expiry) -} - -/** - * Parses an Authorization Bearer token string into a map of fields. - * - * Expects format `Bearer ` where the decoded JSON contains string key/value - * pairs such as `{"access-token":"...","client":"...","uid":"...","expiry":"..."}`. - * - * This function includes size validation to prevent memory exhaustion attacks. Tokens exceeding the - * specified maximum size are rejected before decoding. - * - * @param bearer The bearer token string, with or without the `Bearer ` prefix. - * @param maxSize Maximum allowed token size in bytes (default: [DEFAULT_MAX_BEARER_TOKEN_SIZE]). - * @return A map of parsed string fields, or `null` if the token is too large, base64 decoding - * fails, or JSON parsing fails. - */ -fun parseBearerToken( - bearer: String, - maxSize: Int = DEFAULT_MAX_BEARER_TOKEN_SIZE -): Map? { - val token = bearer.trim().removePrefix("Bearer ").trim() - - // Validate size before Base64 decoding to prevent memory exhaustion attacks - if (token.length > maxSize) { - logger.warn( - "Bearer token exceeds maximum size: {} bytes (max: {})", - token.length, - maxSize, - ) - return null - } - - return try { - val json = String(Base64.getDecoder().decode(token), Charsets.UTF_8) - - // Also validate decoded JSON size as a secondary check - if (json.length > maxSize) { - logger.warn("Decoded bearer token exceeds maximum size: {} bytes", json.length) - return null - } - - @Suppress("UNCHECKED_CAST") - mapper.readValue(json, Map::class.java) as? Map - } catch (e: IllegalArgumentException) { - logger.debug("Failed to decode Base64 bearer token: {}", e.message) - null - } catch (e: JsonProcessingException) { - logger.debug("Failed to parse bearer token JSON: {}", e.message) - null - } catch (e: IOException) { - logger.debug("I/O error parsing bearer token: {}", e.message) - null - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/IdentifierPolicy.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/IdentifierPolicy.kt deleted file mode 100644 index 80a6c82..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/IdentifierPolicy.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -import java.security.SecureRandom - -/** - * Minimum character length for generated identifiers. - * - * Enforced by [DefaultIdentifierPolicy] on construction. Custom [IdentifierPolicy] implementations - * should respect this floor to maintain the library's security baseline. - */ -const val MIN_TOKEN_LENGTH = 32 - -/** - * Strategy for generating and validating opaque string identifiers used as token values and client - * IDs. - * - * Implementations must produce cryptographically random strings of sufficient entropy. - * [DefaultIdentifierPolicy] uses [java.security.SecureRandom] over the URL-safe alphabet - * `A-Za-z0-9._-` with a minimum length of [MIN_TOKEN_LENGTH]. - */ -interface IdentifierPolicy { - fun generate(): String - - /** - * Returns `true` if [value] is a non-blank string that conforms to this policy's identifier - * format. `null` and blank strings always return `false`. - */ - fun isValid(value: String?): Boolean -} - -class DefaultIdentifierPolicy( - private val length: Int = MIN_TOKEN_LENGTH, -) : IdentifierPolicy { - init { - require(length >= MIN_TOKEN_LENGTH) { - "Token length must be at least $MIN_TOKEN_LENGTH characters (configured: $length)" - } - } - - private val random = SecureRandom() - private val alphabet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-".toCharArray() - private val validator = Regex("^[A-Za-z0-9._-]{$MIN_TOKEN_LENGTH,${length.coerceAtLeast(64)}}$") - - /** - * Generates an identifier of the configured length composed of characters from the allowed - * alphabet. - * - * @return A string identifier whose length equals the policy's `length` and that contains only - * characters from `A–Z`, `a–z`, `0–9`, `.`, `_`, and `-`. - */ - override fun generate(): String { - val builder = StringBuilder(length) - repeat(length) { builder.append(alphabet[random.nextInt(alphabet.size)]) } - return builder.toString() - } - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() && validator.matches(value) -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/JsonCodec.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/JsonCodec.kt deleted file mode 100644 index 8065987..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/JsonCodec.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper - -/** - * Internal shared [ObjectMapper] used by ogiri-core for bearer-token JSON encoding/decoding. - * - * Consumers may reference [mapper] for compatible serialisation, but must not mutate it. Configured - * as a standard Kotlin-module-aware mapper with no custom serializers. - */ -object JsonCodec { - val mapper: ObjectMapper = jacksonObjectMapper() -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/OgiriService.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/OgiriService.kt deleted file mode 100644 index c8b0af2..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/OgiriService.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -/** - * Meta-annotation that marks Ogiri service classes for the all-open compiler plugin. - * - * Classes annotated with @OgiriService have all methods compiled as non-final, ensuring CGLIB proxy - * interception works for @Transactional methods. The ogiri-core build configures the all-open - * plugin to recognize this annotation. - * - * This annotation intentionally does NOT include @Component to avoid interfering with Spring's - * component scanning — OgiriTokenService is registered via auto-configuration, not component scan. - */ -@Target(AnnotationTarget.CLASS) -@Retention(AnnotationRetention.RUNTIME) -annotation class OgiriService diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/SecurityServiceException.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/SecurityServiceException.kt deleted file mode 100644 index c9b7d19..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/core/SecurityServiceException.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -class SecurityServiceException( - val code: String, - vararg val args: Any?, -) : RuntimeException(code) diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDecider.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDecider.kt deleted file mode 100644 index 13eefef..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDecider.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.helpers - -import com.quantipixels.ogiri.security.routes.OgiriRouteCatalog -import jakarta.servlet.http.HttpServletRequest -import org.springframework.http.HttpMethod -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.util.matcher.RequestMatcher - -/** - * Centralizes logic to skip authentication when appropriate (already authenticated, bypassed paths, - * CORS preflight, or public routes). - */ -class AuthenticationBypassDecider( - private val routeCatalog: OgiriRouteCatalog, - private val bypassMatcher: RequestMatcher = RequestMatcher { false }, -) { - /** - * Determines whether authentication may be skipped for the given HTTP request. - * - * @param request The incoming HTTP servlet request to evaluate. - * @return `true` if the request is already authenticated, matches a bypass matcher, is a CORS - * preflight request, or targets a public route; `false` otherwise. - */ - fun canSkip(request: HttpServletRequest): Boolean { - val isAuthenticated = SecurityContextHolder.getContext().authentication != null - val isBypassed = bypassMatcher.matches(request) - val isPreflight = SecurityHelpers.isPreflight(request.method) - val method = runCatching { HttpMethod.valueOf(request.method) }.getOrNull() - val isPublicRoute = method?.let { routeCatalog.isPublicRoute(request.requestURI, it) } ?: false - return isAuthenticated || isBypassed || isPreflight || isPublicRoute - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpers.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpers.kt deleted file mode 100644 index 0b719e8..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpers.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.helpers - -import jakarta.servlet.http.HttpServletRequest -import java.net.Inet4Address -import java.net.Inet6Address -import java.net.InetAddress -import java.net.UnknownHostException -import org.slf4j.LoggerFactory -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.web.context.request.RequestContextHolder -import org.springframework.web.context.request.ServletRequestAttributes - -object SecurityHelpers { - private val logger = LoggerFactory.getLogger(SecurityHelpers::class.java) - private val IP_PATTERN = Regex("^[\\d.:a-fA-F%]+$") - - /** - * Validates whether the given string is a valid IP address (IPv4 or IPv6). - * - * Uses [InetAddress] for robust validation instead of regex patterns, which correctly handles: - * - Standard IPv4 addresses (e.g., "192.168.1.1") - * - Full IPv6 addresses (e.g., "2001:db8::1") - * - Compressed IPv6 (e.g., "::", "::1") - * - IPv4-mapped IPv6 (e.g., "::ffff:192.168.1.1") - * - * Note: IPv6 zone IDs (e.g., "fe80::1%eth0") are stripped before validation as they are - * interface-specific suffixes that don't affect the validity of the IP address itself. - * - * @param ip The string to validate. - * @return `true` if the string is a valid IPv4 or IPv6 address, `false` otherwise. - */ - fun isValidIp(ip: String): Boolean { - if (ip == "localhost") return true - if (ip.isBlank()) return false - - // Strip IPv6 zone ID (e.g., "fe80::1%eth0" -> "fe80::1") - val ipWithoutZone = ip.substringBefore('%') - - // Pre-check to prevent DNS resolution on non-IP inputs - if (!IP_PATTERN.matches(ipWithoutZone)) return false - - return try { - // InetAddress.getByName validates and parses the IP address - val addr = InetAddress.getByName(ipWithoutZone) - when (addr) { - is Inet4Address -> { - // InetAddress.getByName parses various formats as IPv4: - // - Standard: "192.168.1.1" (valid) - // - IPv4-mapped IPv6: "::ffff:192.168.1.1" (valid - special case) - // - Single number: "12345" (invalid for our purposes) - // - Partial: "192.168.1" (invalid for our purposes) - // - // Accept if it's either a proper 4-octet IPv4 or an IPv4-mapped IPv6 format - val isStandardIpv4 = - ipWithoutZone.count { it == '.' } == 3 && - ipWithoutZone.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$")) - val isIpv4MappedIpv6 = ipWithoutZone.startsWith("::ffff:", ignoreCase = true) - isStandardIpv4 || isIpv4MappedIpv6 - } - is Inet6Address -> true - else -> false - } - } catch (e: Exception) { - when (e) { - is UnknownHostException, - is SecurityException, - is IllegalArgumentException -> { - logger.trace("IP validation failed (length={}): {}", ip.length, e.message) - false - } - else -> throw e - } - } - } - - /** - * Extracts the client's IP address from the given servlet request. - * - * Returns request.remoteAddr which Spring Boot populates from the actual client IP when - * server.forward-headers-strategy=NATIVE is configured. Manual header parsing is error-prone and - * CVE-prone. - * - * @param request The incoming HTTP servlet request. - * @return The client's IP address from request.remoteAddr. - */ - fun getClientIP(request: HttpServletRequest): String? = request.remoteAddr - - /** - * Returns the client IP address of the current request, or `null` if called outside a servlet - * request context (e.g., from a background thread). - * - * Relies on [RequestContextHolder]. Accurate IP resolution behind a reverse proxy requires - * `server.forward-headers-strategy=NATIVE`. - */ - val clientIP: String? - get() { - val attributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes - return attributes?.let { getClientIP(it.request) } - } - - /** - * Returns `true` if [method] is `OPTIONS` (case-insensitive), indicating a CORS preflight - * request. Does not validate CORS headers — only the HTTP method is checked. - */ - fun isPreflight(method: String?): Boolean = "OPTIONS".equals(method, ignoreCase = true) - - /** - * Returns `true` if the current thread's - * [org.springframework.security.core.context.SecurityContext] contains a non-null authentication - * object. - */ - fun isAuthenticated(): Boolean = SecurityContextHolder.getContext().authentication != null -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRoute.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRoute.kt deleted file mode 100644 index 3dcc843..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRoute.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.routes - -import java.nio.charset.StandardCharsets -import org.springframework.http.HttpMethod -import org.springframework.web.util.UriUtils - -/** Describes an HTTP route for public/auth and rate-limit configuration. */ -class OgiriRoute( - val method: HttpMethod, - val path: String, - val rateLimit: Boolean = true, - val useAuth: Boolean = true, - val rateLimitPermitsPerMinute: Long? = null, -) { - init { - require(path.startsWith('/')) { "Path must start with '/'" } - } - - private val braceVarRegex = Regex("\\{([a-zA-Z0-9_\\-]+)\\}") - private val colonVarRegex = Regex(":([a-zA-Z0-9_\\-]+)") - - /** - * URL-encodes the given value using UTF-8. - * - * @param value The value to encode; it is converted to a string before encoding. - * @return The percent-encoded string representation of the value using UTF-8. - */ - private fun encode(value: Any): String = - UriUtils.encodePathSegment(value.toString(), StandardCharsets.UTF_8.toString()) - - /** - * Substitutes path variable placeholders in this route's path with the provided parameter values. - * - * @param params A map from variable name (without braces or leading colon) to the value to - * insert; values are converted to strings and URL-encoded. - * @return The route path with all `{name}` and `:name` placeholders replaced by their - * corresponding URL-encoded values. - * @throws IllegalArgumentException If a placeholder in the path has no corresponding entry in - * `params`. - */ - fun apply(params: Map): String { - var result = path - braceVarRegex.findAll(path).forEach { match -> - val name = match.groupValues[1] - val value = params[name] ?: throw IllegalArgumentException("Missing path param '$name'") - result = result.replace("{$name}", encode(value)) - } - colonVarRegex.findAll(result).forEach { match -> - val name = match.groupValues[1] - val value = params[name] ?: throw IllegalArgumentException("Missing path param '$name'") - result = result.replace(":$name", encode(value)) - } - return result - } - - fun apply(vararg params: Pair): String = apply(params.toMap()) - - /** - * Returns this route's [path] with all path-variable placeholders (`{name}` and `:name`) replaced - * by `*` for Ant-style pattern matching. - * - * Used internally by route catalog bypass checks; prefer [apply] when constructing real URLs. - */ - fun pathWithWildcardVariables(): String { - var pattern = path - pattern = braceVarRegex.replace(pattern) { "*" } - pattern = colonVarRegex.replace(pattern) { "*" } - return pattern - } - - companion object { - /** - * Create an OgiriRoute configured for HTTP GET requests. - * - * @param path The route path; must start with '/'. - * @param rateLimit Whether rate limiting is enabled for the route. - * @param useAuth Whether authentication is required for the route. - * @param rateLimitPermitsPerMinute Optional quota of permits per minute for rate limiting. - * @return An OgiriRoute representing the GET route with the specified options. - */ - fun get( - path: String, - rateLimit: Boolean = true, - useAuth: Boolean = true, - rateLimitPermitsPerMinute: Long? = null, - ) = OgiriRoute(HttpMethod.GET, path, rateLimit, useAuth, rateLimitPermitsPerMinute) - - /** Create an OgiriRoute for HTTP POST. Parameters are identical to [get]. */ - fun post( - path: String, - rateLimit: Boolean = true, - useAuth: Boolean = true, - rateLimitPermitsPerMinute: Long? = null, - ) = OgiriRoute(HttpMethod.POST, path, rateLimit, useAuth, rateLimitPermitsPerMinute) - - /** Create an OgiriRoute for HTTP PUT. Parameters are identical to [get]. */ - fun put( - path: String, - rateLimit: Boolean = true, - useAuth: Boolean = true, - rateLimitPermitsPerMinute: Long? = null, - ) = OgiriRoute(HttpMethod.PUT, path, rateLimit, useAuth, rateLimitPermitsPerMinute) - - /** Create an OgiriRoute for HTTP PATCH. Parameters are identical to [get]. */ - fun patch( - path: String, - rateLimit: Boolean = true, - useAuth: Boolean = true, - rateLimitPermitsPerMinute: Long? = null, - ) = OgiriRoute(HttpMethod.PATCH, path, rateLimit, useAuth, rateLimitPermitsPerMinute) - - /** Create an OgiriRoute for HTTP DELETE. Parameters are identical to [get]. */ - fun delete( - path: String, - rateLimit: Boolean = true, - useAuth: Boolean = true, - rateLimitPermitsPerMinute: Long? = null, - ) = OgiriRoute(HttpMethod.DELETE, path, rateLimit, useAuth, rateLimitPermitsPerMinute) - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteCatalog.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteCatalog.kt deleted file mode 100644 index 42b8f90..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteCatalog.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.routes - -import org.springframework.http.HttpMethod -import org.springframework.util.AntPathMatcher - -/** - * Aggregates registered [OgiriRouteRegistry] beans for quick lookup in filters and rate limiting. - */ -class OgiriRouteCatalog( - registries: List, -) { - private val matcher = AntPathMatcher() - private val configuredRoutes: List = registries.flatMap { it.routes() } - private val publicRoutes: List = configuredRoutes.filterNot { it.useAuth } - - /** Routes configured by all registered [OgiriRouteRegistry] implementations. */ - fun configured(): List = configuredRoutes - - /** Routes that do not require authentication (`useAuth = false`). */ - fun public(): List = publicRoutes - - /** - * Determines whether the given request URI and HTTP method match any configured public route. - * - * @param uri The request URI to test against registered routes. - * @param method The HTTP method to match; when `null` the function will return `false`. - * @return `true` if a configured public route matches the URI and method, `false` otherwise - * (including when `method` is `null`). - */ - fun isPublicRoute( - uri: String, - method: HttpMethod?, - ): Boolean { - if (method == null) return false - return publicRoutes.any { route -> - route.method == method && matcher.match(route.pathWithWildcardVariables(), uri) - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteRegistry.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteRegistry.kt deleted file mode 100644 index d8e57e2..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/routes/OgiriRouteRegistry.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.routes - -/** Contracts for modules/apps to expose their routes to security filters. */ -interface OgiriRouteRegistry { - /** - * Exposes the module's HTTP routes for discovery by the application. - * - * @return A list of OgiriRoute instances representing the routes provided by the implementing - * module. - */ - fun routes(): List -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriHttpConfigurer.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriHttpConfigurer.kt deleted file mode 100644 index f35c804..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriHttpConfigurer.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import jakarta.servlet.FilterChain -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import java.util.function.Supplier -import org.springframework.http.HttpHeaders -import org.springframework.security.authentication.AnonymousAuthenticationToken -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.authentication.InsufficientAuthenticationException -import org.springframework.security.authentication.ProviderManager -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.access.AccessDeniedHandlerImpl -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter -import org.springframework.security.web.authentication.AuthenticationConverter -import org.springframework.security.web.authentication.AuthenticationFilter -import org.springframework.security.web.context.RequestAttributeSecurityContextRepository -import org.springframework.security.web.csrf.CookieCsrfTokenRepository -import org.springframework.security.web.csrf.CsrfException -import org.springframework.security.web.csrf.CsrfFilter -import org.springframework.security.web.csrf.CsrfToken -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler -import org.springframework.security.web.csrf.CsrfTokenRequestHandler -import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler -import org.springframework.web.filter.OncePerRequestFilter - -public class OgiriHttpConfigurer( - private val provider: OgiriSessionAuthenticationProvider, - private val entryPoint: OgiriProblemAuthenticationEntryPoint, - private val properties: OgiriSessionProperties, -) : AbstractHttpConfigurer() { - override fun init(http: HttpSecurity) { - http - .formLogin { it.disable() } - .httpBasic { it.disable() } - .logout { it.disable() } - .requestCache { it.disable() } - .anonymous { it.disable() } - val forbidden = AccessDeniedHandlerImpl() - http.exceptionHandling { - it.authenticationEntryPoint(entryPoint).accessDeniedHandler { request, response, denied -> - if (denied is CsrfException) { - forbidden.handle(request, response, denied) - } else { - val authentication = SecurityContextHolder.getContext().authentication - if (authentication == null || - !authentication.isAuthenticated || - authentication is AnonymousAuthenticationToken) { - entryPoint.commence( - request, - response, - InsufficientAuthenticationException("authentication_required", denied), - ) - } else { - forbidden.handle(request, response, denied) - } - } - } - } - if (properties.transport == OgiriTransport.COOKIE) { - http - .csrf { - it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(OgiriSpaCsrfTokenRequestHandler()) - } - .addFilterAfter(OgiriCsrfCookieFilter(), CsrfFilter::class.java) - } else { - // Header credentials are not ambient browser authority; CSRF applies only to cookie mode. - // lgtm[java/spring-disabled-csrf-protection] - http.csrf { it.disable() } - } - } - - override fun configure(http: HttpSecurity) { - val manager: AuthenticationManager = ProviderManager(provider) - val filter = AuthenticationFilter(manager, converter()) - filter.setFailureHandler(entryPoint) - filter.setSuccessHandler { _, _, _ -> } - filter.setSecurityContextRepository(RequestAttributeSecurityContextRepository()) - http - .authenticationProvider(provider) - .addFilterBefore(filter, AnonymousAuthenticationFilter::class.java) - } - - private fun converter(): AuthenticationConverter = - when (properties.transport) { - OgiriTransport.BEARER -> - OgiriBearerAuthenticationConverter(properties.maximumCredentialBytes) - OgiriTransport.COOKIE -> AuthenticationConverter(::cookieCredential) - OgiriTransport.DTA_COMPAT -> AuthenticationConverter(::dtaCredential) - } - - private fun cookieCredential(request: HttpServletRequest): OgiriSessionAuthenticationToken? { - val values = request.cookies?.filter { it.name == properties.cookie.name }.orEmpty() - if (values.isEmpty()) return null - if (values.size != 1 || values.single().value.isBlank()) { - throw BadCredentialsException("malformed_cookie_credential") - } - return OgiriSessionAuthenticationToken.unauthenticated(values.single().value) - } - - private fun dtaCredential(request: HttpServletRequest): OgiriSessionAuthenticationToken? { - if (request.getHeader(HttpHeaders.AUTHORIZATION) != null) { - throw BadCredentialsException("ambiguous_credential_transport") - } - val token = request.getHeader("access-token") ?: return null - if (token.isBlank()) throw BadCredentialsException("malformed_dta_credential") - return OgiriSessionAuthenticationToken.unauthenticated(token) - } - - public companion object { - @JvmStatic - public fun apply(http: HttpSecurity, configurer: OgiriHttpConfigurer): HttpSecurity = - http.with(configurer) {} - } -} - -private class OgiriSpaCsrfTokenRequestHandler : CsrfTokenRequestHandler { - private val plain = CsrfTokenRequestAttributeHandler() - private val xor = XorCsrfTokenRequestAttributeHandler() - - override fun handle( - request: HttpServletRequest, - response: HttpServletResponse, - csrfToken: Supplier, - ) { - xor.handle(request, response, csrfToken) - } - - override fun resolveCsrfTokenValue(request: HttpServletRequest, csrfToken: CsrfToken): String? = - if (request.getHeader(csrfToken.headerName).isNullOrBlank()) { - xor.resolveCsrfTokenValue(request, csrfToken) - } else { - plain.resolveCsrfTokenValue(request, csrfToken) - } -} - -private class OgiriCsrfCookieFilter : OncePerRequestFilter() { - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - (request.getAttribute(CsrfToken::class.java.name) as? CsrfToken)?.token - filterChain.doFilter(request, response) - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandler.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandler.kt deleted file mode 100644 index 8e2bb7c..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandler.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.SessionError -import java.net.URI -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.http.CacheControl -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus -import org.springframework.http.ProblemDetail -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.MethodArgumentNotValidException -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice - -@RestControllerAdvice(assignableTypes = [OgiriSessionEndpointController::class]) -@ConditionalOnProperty( - prefix = "ogiri.session.endpoints", - name = ["enabled"], - havingValue = "true", -) -public class OgiriProblemHandler { - @ExceptionHandler(SessionError::class) - public fun sessionError(error: SessionError): ResponseEntity { - val status = - when (error) { - is SessionError.SubjectUnavailable -> HttpStatus.FORBIDDEN - is SessionError.Conflict, - is SessionError.SessionLimitReached -> HttpStatus.CONFLICT - else -> HttpStatus.UNAUTHORIZED - } - return response(status, error.code) - } - - @ExceptionHandler(OgiriRateLimitExceeded::class) - public fun rateLimited(error: OgiriRateLimitExceeded): ResponseEntity = - response( - HttpStatus.TOO_MANY_REQUESTS, - "rate_limit_exceeded", - error.retryAfter.seconds.coerceAtLeast(1), - ) - - @ExceptionHandler(IllegalArgumentException::class) - public fun malformedRequest(): ResponseEntity = - response(HttpStatus.BAD_REQUEST, "malformed_request") - - @ExceptionHandler(MethodArgumentNotValidException::class) - public fun invalidRequest(): ResponseEntity = - response(HttpStatus.UNPROCESSABLE_ENTITY, "invalid_request") - - private fun response( - status: HttpStatus, - code: String, - retryAfterSeconds: Long? = null, - ): ResponseEntity { - val problem = ProblemDetail.forStatusAndDetail(status, status.reasonPhrase) - problem.type = URI.create("https://quantipixels.com/problems/$code") - problem.title = status.reasonPhrase - problem.setProperty("code", code) - val headers = HttpHeaders() - headers.cacheControl = CacheControl.noStore().headerValue - headers.pragma = "no-cache" - if (status == HttpStatus.UNAUTHORIZED) { - headers.set(HttpHeaders.WWW_AUTHENTICATE, "Bearer error=\"invalid_token\"") - } - retryAfterSeconds?.let { headers.set(HttpHeaders.RETRY_AFTER, it.toString()) } - return ResponseEntity(problem, headers, status) - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriRateLimiter.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriRateLimiter.kt deleted file mode 100644 index 3f6f9fc..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriRateLimiter.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import java.time.Duration -import java.time.Instant - -/** Result of atomically consuming one rate-limit permit. */ -public data class OgiriRateLimitDecision( - val allowed: Boolean, - val remaining: Long, - val retryAfter: Duration, -) - -/** Application or infrastructure boundary for fixed-window request limiting. */ -public fun interface OgiriRateLimiter { - /** Atomically consumes one permit for a namespaced, non-secret key. */ - public fun consume( - key: String, - permits: Long, - window: Duration, - now: Instant, - ): OgiriRateLimitDecision -} - -/** Signals an exhausted rate limit and carries the duration clients should wait before retrying. */ -public class OgiriRateLimitExceeded(public val retryAfter: Duration) : - RuntimeException("rate_limit_exceeded") diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAuthentication.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAuthentication.kt deleted file mode 100644 index 4cfc332..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAuthentication.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.AuthenticatedSession -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.SessionError -import com.quantipixels.ogiri.session.SessionManager -import jakarta.servlet.http.HttpServletRequest -import java.nio.charset.StandardCharsets -import org.springframework.http.HttpHeaders -import org.springframework.security.authentication.AbstractAuthenticationToken -import org.springframework.security.authentication.AuthenticationProvider -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.core.Authentication -import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.web.authentication.AuthenticationConverter - -/** Non-secret session identity exposed through Spring Security's [Authentication] principal. */ -public data class OgiriSessionPrincipal( - val subject: String, - val realm: String, - val tenant: String?, - val sessionId: String, - val clientId: String, - val version: Long, - val familyId: String, -) - -/** Maps an authenticated session to application-specific Spring Security authorities. */ -public fun interface OgiriAuthorityResolver { - /** Resolves all authorities granted to [session]. */ - public fun resolve(session: AuthenticatedSession): Collection -} - -/** - * Spring Security authentication token for Ogiri credentials and authenticated principals. - * - * The unauthenticated form retains the raw credential only until provider authentication. The - * authenticated form drops it to prevent later disclosure through the security context. - */ -public class OgiriSessionAuthenticationToken -private constructor( - private val rawCredential: String?, - private val sessionPrincipal: OgiriSessionPrincipal?, - authorities: Collection, -) : AbstractAuthenticationToken(authorities) { - init { - isAuthenticated = sessionPrincipal != null - } - - override fun getCredentials(): Any = rawCredential.orEmpty() - - override fun getPrincipal(): Any = sessionPrincipal ?: "" - - public companion object { - /** Creates a provider input carrying an unverified credential. */ - public fun unauthenticated(credential: String): OgiriSessionAuthenticationToken = - OgiriSessionAuthenticationToken(credential, null, emptyList()) - - /** Creates an authenticated token that contains no raw credential. */ - public fun authenticated( - principal: OgiriSessionPrincipal, - authorities: Collection, - ): OgiriSessionAuthenticationToken = - OgiriSessionAuthenticationToken(null, principal, authorities) - } -} - -/** - * Strictly converts one Bearer authorization header into an Ogiri authentication request. - * - * Multiple headers, unsupported schemes, oversized values, and malformed opaque credentials are - * rejected before cryptographic or persistence work. - */ -public class OgiriBearerAuthenticationConverter( - private val maximumCredentialBytes: Int = 256, -) : AuthenticationConverter { - init { - require(maximumCredentialBytes in OpaqueTokenCodec.MIN_CREDENTIAL_CHARS..4096) { - "maximum credential bytes must be between ${OpaqueTokenCodec.MIN_CREDENTIAL_CHARS} and 4096" - } - } - - override fun convert(request: HttpServletRequest): Authentication? { - val values = request.getHeaders(HttpHeaders.AUTHORIZATION).toList() - if (values.isEmpty()) return null - if (values.size != 1) throw BadCredentialsException("multiple_authorization_headers") - val value = values.single() - if (value.toByteArray(StandardCharsets.ISO_8859_1).size > maximumCredentialBytes + 7) { - throw BadCredentialsException("credential_too_large") - } - val separator = value.indexOf(' ') - if (separator <= 0 || value.substring(0, separator).lowercase() != "bearer") { - throw BadCredentialsException("unsupported_authorization_scheme") - } - val credential = value.substring(separator + 1) - if (credential.toByteArray(StandardCharsets.ISO_8859_1).size > maximumCredentialBytes) { - throw BadCredentialsException("credential_too_large") - } - if (!credential.matches(OPAQUE_CREDENTIAL)) { - throw BadCredentialsException("malformed_bearer_credential") - } - return OgiriSessionAuthenticationToken.unauthenticated(credential) - } - - private companion object { - private val OPAQUE_CREDENTIAL = Regex("[A-Za-z0-9_-]{22,86}\\.[A-Za-z0-9_-]{22,86}") - } -} - -/** Authenticates [OgiriSessionAuthenticationToken] instances through [SessionManager]. */ -public class OgiriSessionAuthenticationProvider( - private val sessions: SessionManager, - private val authorityResolver: OgiriAuthorityResolver = OgiriAuthorityResolver { - listOf(SimpleGrantedAuthority("ROLE_USER")) - }, -) : AuthenticationProvider { - override fun authenticate(authentication: Authentication): Authentication { - val raw = - authentication.credentials as? String ?: throw BadCredentialsException("invalid_credential") - val authenticated = - try { - sessions.authenticate(raw) - } catch (error: SessionError) { - throw BadCredentialsException(error.code, error) - } - val principal = - OgiriSessionPrincipal( - subject = authenticated.subject.subjectId.value, - realm = authenticated.subject.realm.value, - tenant = authenticated.subject.tenantId?.value, - sessionId = authenticated.sessionId.value, - clientId = authenticated.client.clientId, - version = authenticated.version, - familyId = authenticated.familyId, - ) - return OgiriSessionAuthenticationToken.authenticated( - principal, - authorityResolver.resolve(authenticated), - ) - } - - override fun supports(authentication: Class<*>): Boolean = - OgiriSessionAuthenticationToken::class.java.isAssignableFrom(authentication) -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAutoConfiguration.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAutoConfiguration.kt deleted file mode 100644 index 3533261..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAutoConfiguration.kt +++ /dev/null @@ -1,329 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.fasterxml.jackson.databind.ObjectMapper -import com.quantipixels.ogiri.session.HmacSha256TokenHasher -import com.quantipixels.ogiri.session.IdentifierGenerator -import com.quantipixels.ogiri.session.NoOpSessionEventPublisher -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.SecureRandomIdentifierGenerator -import com.quantipixels.ogiri.session.SessionEventPublisher -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SessionPolicy -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.SubjectStatusChecker -import com.quantipixels.ogiri.session.TokenCodec -import com.quantipixels.ogiri.session.TokenHasher -import io.micrometer.core.instrument.MeterRegistry -import java.security.SecureRandom -import java.time.Clock -import java.util.Base64 -import org.springframework.beans.factory.ObjectProvider -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.scheduling.TaskScheduler -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler -import org.springframework.security.authentication.AccountStatusException -import org.springframework.security.authentication.AccountStatusUserDetailsChecker -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.core.userdetails.UsernameNotFoundException -import org.springframework.security.web.SecurityFilterChain - -/** - * Spring Boot auto-configuration for the session subsystem. - * - * Activates only when `ogiri.session.enabled=true`. Every extension boundary with an application- - * specific policy or infrastructure implementation backs off when a user bean is present. - */ -@AutoConfiguration(before = [SecurityAutoConfiguration::class]) -@EnableConfigurationProperties(OgiriSessionProperties::class) -@ConditionalOnProperty( - prefix = "ogiri.session", - name = ["enabled"], - havingValue = "true", - matchIfMissing = false, -) -public open class OgiriSessionAutoConfiguration { - @Bean @ConditionalOnMissingBean public open fun ogiriClock(): Clock = Clock.systemUTC() - - @Bean @ConditionalOnMissingBean public open fun ogiriSecureRandom(): SecureRandom = SecureRandom() - - @Bean - @ConditionalOnMissingBean - public open fun ogiriIdentifierGenerator(secureRandom: SecureRandom): IdentifierGenerator = - SecureRandomIdentifierGenerator(secureRandom) - - @Bean - @ConditionalOnMissingBean - public open fun ogiriTokenCodec(secureRandom: SecureRandom): TokenCodec = - OpaqueTokenCodec(secureRandom) - - /** - * Creates the default key-rotatable verifier hasher. - * - * Fails startup rather than issuing credentials when key material is absent or malformed. - */ - @Bean - @ConditionalOnMissingBean - public open fun ogiriTokenHasher(properties: OgiriSessionProperties): TokenHasher { - val configured = properties.tokenHash - require(configured.currentKeyId.isNotBlank()) { - "ogiri.session.token-hash.current-key-id is required unless a TokenHasher bean is supplied" - } - require(configured.keys.isNotEmpty()) { - "ogiri.session.token-hash.keys must contain at least one Base64-encoded 256-bit key" - } - val keys = - configured.keys.mapValues { (id, encoded) -> - runCatching { Base64.getDecoder().decode(encoded) } - .getOrElse { - throw IllegalArgumentException("token-hash key '$id' is not valid Base64") - } - } - return HmacSha256TokenHasher(configured.currentKeyId, keys) - } - - @Bean - @ConditionalOnMissingBean - public open fun ogiriSessionEventPublisher( - registry: ObjectProvider - ): SessionEventPublisher = - registry.getIfAvailable()?.let(::OgiriSessionMetrics) ?: NoOpSessionEventPublisher - - @Bean - @ConditionalOnBean(UserDetailsService::class) - @ConditionalOnMissingBean - public open fun ogiriSubjectStatusChecker(users: UserDetailsService): SubjectStatusChecker { - val checker = AccountStatusUserDetailsChecker() - return SubjectStatusChecker { subject -> - try { - checker.check(users.loadUserByUsername(subject.subjectId.value)) - true - } catch (_: UsernameNotFoundException) { - false - } catch (_: AccountStatusException) { - false - } - } - } - - /** Builds the session coordinator once persistence and subject-status policies are available. */ - @Bean - @ConditionalOnBean(SessionStore::class, SubjectStatusChecker::class) - @ConditionalOnMissingBean - public open fun ogiriSessionManager( - store: SessionStore, - codec: TokenCodec, - hasher: TokenHasher, - statusChecker: SubjectStatusChecker, - clock: Clock, - events: SessionEventPublisher, - identifiers: IdentifierGenerator, - properties: OgiriSessionProperties, - ): SessionManager = - SessionManager( - store, - codec, - hasher, - statusChecker, - clock, - SessionPolicy( - properties.lifetime, - properties.previousVersionGrace, - properties.maximumActiveSessions, - properties.evictOldestWhenFull, - ), - events, - identifiers, - ) - - @Bean - @ConditionalOnBean(SessionManager::class) - @ConditionalOnMissingBean - public open fun ogiriAuthorityResolver(): OgiriAuthorityResolver = OgiriAuthorityResolver { - emptyList() - } - - @Bean - @ConditionalOnBean(SessionManager::class) - public open fun ogiriSessionAuthenticationProvider( - sessions: SessionManager, - authorities: OgiriAuthorityResolver, - ): OgiriSessionAuthenticationProvider = OgiriSessionAuthenticationProvider(sessions, authorities) - - @Bean - public open fun ogiriProblemAuthenticationEntryPoint( - mapper: ObjectMapper - ): OgiriProblemAuthenticationEntryPoint = OgiriProblemAuthenticationEntryPoint(mapper) - - @Bean - @ConditionalOnBean(OgiriSessionAuthenticationProvider::class) - public open fun ogiriHttpConfigurer( - provider: OgiriSessionAuthenticationProvider, - entryPoint: OgiriProblemAuthenticationEntryPoint, - properties: OgiriSessionProperties, - ): OgiriHttpConfigurer = OgiriHttpConfigurer(provider, entryPoint, properties) - - @Bean - public open fun ogiriSessionResponseWriter( - properties: OgiriSessionProperties, - codec: TokenCodec, - ): OgiriSessionResponseWriter = OgiriSessionResponseWriter(properties, codec) - - @Bean - public open fun ogiriRequestCredentialResolver( - properties: OgiriSessionProperties - ): OgiriRequestCredentialResolver = OgiriRequestCredentialResolver(properties) - - @Bean - @ConditionalOnMissingBean - public open fun ogiriSubjectResolver(properties: OgiriSessionProperties): OgiriSubjectResolver = - OgiriSubjectResolver { authentication -> - com.quantipixels.ogiri.session.SubjectRef( - com.quantipixels.ogiri.session.Realm(properties.realm), - com.quantipixels.ogiri.session.SubjectId(authentication.name), - ) - } - - @Bean - @ConditionalOnMissingBean - public open fun ogiriClientContextResolver( - identifiers: IdentifierGenerator - ): OgiriClientContextResolver = OgiriClientContextResolver { request, requestedClientId -> - com.quantipixels.ogiri.session.ClientContext( - requestedClientId?.takeIf(String::isNotBlank) ?: identifiers.next(), - userAgent = request.getHeader("User-Agent"), - ipAddress = request.remoteAddr, - ) - } - - @Bean - @ConditionalOnBean(SessionManager::class) - @ConditionalOnProperty( - prefix = "ogiri.session.endpoints", - name = ["enabled"], - havingValue = "true", - ) - public open fun ogiriSessionEndpointController( - authenticationConfiguration: AuthenticationConfiguration, - authenticationManagers: ObjectProvider, - sessions: SessionManager, - subjectResolver: OgiriSubjectResolver, - clientResolver: OgiriClientContextResolver, - credentialResolver: OgiriRequestCredentialResolver, - responses: OgiriSessionResponseWriter, - rateLimiters: ObjectProvider, - clock: Clock, - properties: OgiriSessionProperties, - ): OgiriSessionEndpointController = - OgiriSessionEndpointController( - authenticationManagers.getIfAvailable { - authenticationConfiguration.authenticationManager - }, - sessions, - subjectResolver, - clientResolver, - credentialResolver, - responses, - rateLimiters.getIfAvailable(), - clock, - properties.rateLimit, - ) - - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty( - prefix = "ogiri.session.endpoints", - name = ["enabled"], - havingValue = "true", - ) - public open fun ogiriProblemHandler(): OgiriProblemHandler = OgiriProblemHandler() - - @Bean - @ConditionalOnProperty( - prefix = "ogiri.session.cleanup", - name = ["enabled"], - havingValue = "true", - ) - @ConditionalOnMissingBean(OgiriJobLease::class) - public open fun ogiriMissingJobLease(): OgiriJobLease = - throw IllegalStateException( - "ogiri.session.cleanup.enabled requires a cluster-safe OgiriJobLease bean") - - @Bean(destroyMethod = "shutdown") - @ConditionalOnProperty( - prefix = "ogiri.session.cleanup", - name = ["enabled"], - havingValue = "true", - ) - @ConditionalOnMissingBean(TaskScheduler::class) - public open fun ogiriCleanupTaskScheduler(): ThreadPoolTaskScheduler = - ThreadPoolTaskScheduler().apply { - poolSize = 1 - setThreadNamePrefix("ogiri-cleanup-") - setWaitForTasksToCompleteOnShutdown(true) - initialize() - } - - @Bean - @ConditionalOnBean(SessionManager::class, OgiriJobLease::class) - @ConditionalOnProperty( - prefix = "ogiri.session.cleanup", - name = ["enabled"], - havingValue = "true", - ) - public open fun ogiriSessionCleanupScheduler( - sessions: SessionManager, - lease: OgiriJobLease, - scheduler: TaskScheduler, - clock: Clock, - properties: OgiriSessionProperties, - ): OgiriSessionCleanupScheduler = - OgiriSessionCleanupScheduler(sessions, lease, scheduler, clock, properties.cleanup) - - /** - * Supplies a stateless default security chain when the application has not declared one. - * - * Configured public paths are permitted and every other request requires authentication. - */ - @Bean("ogiriSecureSecurityFilterChain") - @ConditionalOnBean(OgiriHttpConfigurer::class) - @ConditionalOnDefaultWebSecurity - public open fun ogiriSecureSecurityFilterChain( - http: HttpSecurity, - configurer: OgiriHttpConfigurer, - properties: OgiriSessionProperties, - ): SecurityFilterChain { - http - .with(configurer) {} - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { - if (properties.publicPaths.isNotEmpty()) { - it.requestMatchers(*properties.publicPaths.toTypedArray()).permitAll() - } - it.anyRequest().authenticated() - } - return http.build() - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanup.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanup.kt deleted file mode 100644 index 9ecd2c7..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanup.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.SessionManager -import java.time.Clock -import java.time.Instant -import java.util.UUID -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference -import org.springframework.context.SmartLifecycle -import org.springframework.scheduling.TaskScheduler - -/** - * Cluster-wide lease used to ensure only one node performs a named maintenance job. - * - * Implementations must acquire atomically and must not let one owner release another owner's lease. - */ -public interface OgiriJobLease { - /** - * Acquires an available lease or renews it when [owner] already holds it, extending it to - * [until]. Returns `false` while another owner holds an unexpired lease. - */ - public fun tryAcquire(name: String, owner: String, now: Instant, until: Instant): Boolean - - /** Releases [name] only if it is still held by [owner]. */ - public fun release(name: String, owner: String): Unit -} - -/** Latest observable outcome of the session cleanup scheduler. */ -public data class OgiriCleanupStatus( - val lastStartedAt: Instant?, - val lastCompletedAt: Instant?, - val lastDeletedRows: Int, - val lastFailure: String?, -) - -/** - * Lifecycle-managed cleanup loop that deletes bounded pages under a cluster-safe lease. - * - * A run continues until a short page is returned. Failures are recorded in [status] and rethrown so - * the configured scheduler can apply its normal error handling. - */ -public class OgiriSessionCleanupScheduler( - private val sessions: SessionManager, - private val lease: OgiriJobLease, - private val scheduler: TaskScheduler, - private val clock: Clock, - private val properties: OgiriSessionProperties.Cleanup, -) : SmartLifecycle { - private val owner = UUID.randomUUID().toString() - private val running = AtomicBoolean(false) - private val status = AtomicReference(OgiriCleanupStatus(null, null, 0, null)) - private var future: ScheduledFuture<*>? = null - - override fun start() { - if (running.compareAndSet(false, true)) { - future = scheduler.scheduleWithFixedDelay(::runOnce, properties.interval) - } - } - - override fun stop() { - future?.cancel(false) - running.set(false) - } - - override fun isRunning(): Boolean = running.get() - - /** Returns the latest immutable cleanup status snapshot. */ - public fun status(): OgiriCleanupStatus = status.get() - - /** - * Attempts one leased cleanup run immediately; does nothing when another owner holds the lease. - */ - public fun runOnce() { - val started = clock.instant() - if (!lease.tryAcquire(JOB_NAME, owner, started, started.plus(properties.lease))) return - val deadline = started.plus(properties.maxRunDuration) - status.set(OgiriCleanupStatus(started, null, 0, null)) - var deleted = 0 - try { - while (true) { - val page = sessions.cleanupPage(properties.batchSize) - deleted += page - if (page < properties.batchSize) break - - val now = clock.instant() - if (!now.isBefore(deadline)) break - if (!lease.tryAcquire(JOB_NAME, owner, now, now.plus(properties.lease))) break - } - status.set(OgiriCleanupStatus(started, clock.instant(), deleted, null)) - } catch (error: RuntimeException) { - status.set( - OgiriCleanupStatus( - started, - clock.instant(), - deleted, - error::class.qualifiedName ?: "cleanup_failure", - )) - throw error - } finally { - lease.release(JOB_NAME, owner) - } - } - - private companion object { - private const val JOB_NAME = "session-cleanup" - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionEndpoints.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionEndpoints.kt deleted file mode 100644 index 2faf73a..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionEndpoints.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.AuthenticatedSession -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.SessionError -import com.quantipixels.ogiri.session.SessionId -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.StoredSession -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.session.TenantId -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import jakarta.validation.Valid -import jakarta.validation.constraints.NotBlank -import java.time.Clock -import java.time.Instant -import java.util.Locale -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController - -/** Converts successful primary authentication into the subject that will own a session. */ -public fun interface OgiriSubjectResolver { - /** Resolves the canonical session subject for [authentication]. */ - public fun resolve(authentication: Authentication): SubjectRef -} - -/** Derives trusted client metadata for a newly issued session. */ -public fun interface OgiriClientContextResolver { - /** Resolves client metadata, treating [requestedClientId] as untrusted request input. */ - public fun resolve(request: HttpServletRequest, requestedClientId: String?): ClientContext -} - -/** JSON request accepted by the optional sign-in endpoint. */ -public data class SignInRequest( - @field:NotBlank val username: String, - @field:NotBlank val password: String, - val clientId: String? = null, -) - -/** Non-secret session representation returned by session-management endpoints. */ -public data class SessionView( - val id: String, - val subject: String, - val realm: String, - val tenant: String?, - val clientId: String, - val clientLabel: String?, - val createdAt: Instant, - val lastUsedAt: Instant, - val expiresAt: Instant, - val current: Boolean, -) - -/** - * Optional REST controller for the complete session lifecycle under the configured endpoint path. - * - * The controller is created only when `ogiri.session.endpoints.enabled=true`. Credentials are - * written through [OgiriSessionResponseWriter] and never included in response bodies. - */ -@ConditionalOnProperty( - prefix = "ogiri.session.endpoints", - name = ["enabled"], - havingValue = "true", -) -@RestController -@RequestMapping("\${ogiri.session.endpoints.base-path:/auth}") -public class OgiriSessionEndpointController( - private val authenticationManager: AuthenticationManager, - private val sessions: SessionManager, - private val subjectResolver: OgiriSubjectResolver, - private val clientResolver: OgiriClientContextResolver, - private val credentialResolver: OgiriRequestCredentialResolver, - private val responses: OgiriSessionResponseWriter, - private val rateLimiter: OgiriRateLimiter?, - private val clock: Clock, - private val rateLimit: OgiriSessionProperties.RateLimit, -) { - init { - require(!rateLimit.enabled || rateLimiter != null) { - "ogiri.session.rate-limit.enabled requires an OgiriRateLimiter bean" - } - } - - /** Authenticates primary credentials and issues a new client session. */ - @PostMapping("/sign-in") - @ResponseStatus(HttpStatus.CREATED) - public fun signIn( - @Valid @RequestBody body: SignInRequest, - request: HttpServletRequest, - response: HttpServletResponse, - ): SessionView { - enforceSignInRateLimit(request, body.username) - val authentication = - authenticationManager.authenticate( - UsernamePasswordAuthenticationToken.unauthenticated(body.username, body.password)) - val issued = - sessions.issue( - subjectResolver.resolve(authentication), - clientResolver.resolve(request, body.clientId), - ) - responses.writeCredential(response, issued) - return issued.session.toView(current = true) - } - - /** Rotates the request's current session credential. */ - @PostMapping("/refresh") - public fun refresh(request: HttpServletRequest, response: HttpServletResponse): SessionView { - val issued = sessions.rotate(credentialResolver.resolveRequired(request)) - responses.writeCredential(response, issued) - return issued.session.toView(current = true) - } - - /** Revokes the current session and clears its response credential. */ - @DeleteMapping("/sign-out") - @ResponseStatus(HttpStatus.NO_CONTENT) - public fun signOut(authentication: Authentication, response: HttpServletResponse) { - sessions.revoke(authentication.authenticatedSession()) - responses.clearCredential(response) - } - - /** Returns the current session as stored, or reports it as revoked when no longer active. */ - @GetMapping("/session") - public fun current(authentication: Authentication): SessionView { - val current = authentication.authenticatedSession() - return sessions - .list(current.subject) - .firstOrNull { it.id == current.sessionId } - ?.toView(current = true) - ?: throw SessionError.Revoked() - } - - /** Lists every active session belonging to the current subject. */ - @GetMapping("/sessions") - public fun list(authentication: Authentication): List { - val current = authentication.authenticatedSession() - return sessions.list(current.subject).map { it.toView(current = it.id == current.sessionId) } - } - - /** Revokes a subject-owned session by ID without revealing whether another subject owns it. */ - @DeleteMapping("/sessions/{sessionId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public fun revoke( - authentication: Authentication, - @PathVariable sessionId: String, - ) { - sessions.revoke(authentication.authenticatedSession().subject, SessionId(sessionId)) - } - - /** Revokes every active session except the current session. */ - @DeleteMapping("/sessions") - @ResponseStatus(HttpStatus.NO_CONTENT) - public fun revokeOthers(authentication: Authentication) { - sessions.revokeOthers(authentication.authenticatedSession()) - } - - private fun enforceSignInRateLimit(request: HttpServletRequest, username: String) { - if (!rateLimit.enabled) return - val limiter = requireNotNull(rateLimiter) - val now = clock.instant() - val keys = - listOf( - "sign-in:ip:${request.remoteAddr}", - "sign-in:identifier:${username.trim().lowercase(Locale.ROOT)}", - ) - keys.forEach { key -> - val decision = limiter.consume(key, rateLimit.signInPermits, rateLimit.window, now) - if (!decision.allowed) throw OgiriRateLimitExceeded(decision.retryAfter) - } - } - - private fun Authentication.authenticatedSession(): AuthenticatedSession { - val value = - principal as? OgiriSessionPrincipal - ?: throw IllegalStateException("Ogiri session principal is required") - return AuthenticatedSession( - SessionId(value.sessionId), - SubjectRef( - Realm(value.realm), - SubjectId(value.subject), - value.tenant?.let(::TenantId), - ), - ClientContext(value.clientId), - value.version, - value.familyId, - false, - ) - } -} - -/** Extracts a session credential from the transport selected in [OgiriSessionProperties]. */ -public class OgiriRequestCredentialResolver(private val properties: OgiriSessionProperties) { - /** Resolves a credential or throws when the configured transport contains none. */ - public fun resolveRequired(request: HttpServletRequest): String = - resolve(request) ?: throw IllegalArgumentException("session credential is required") - - /** Resolves the configured credential transport, returning `null` when absent or malformed. */ - public fun resolve(request: HttpServletRequest): String? { - return when (properties.transport) { - OgiriTransport.BEARER -> { - val value = request.getHeader(HttpHeaders.AUTHORIZATION) ?: return null - val parts = value.split(' ', limit = 2) - if (parts.size != 2 || !parts[0].equals("Bearer", ignoreCase = true)) return null - parts[1] - } - OgiriTransport.COOKIE -> - request.cookies?.singleOrNull { it.name == properties.cookie.name }?.value - OgiriTransport.DTA_COMPAT -> request.getHeader("access-token") - } - } -} - -private fun StoredSession.toView(current: Boolean): SessionView = - SessionView( - id.value, - subject.subjectId.value, - subject.realm.value, - subject.tenantId?.value, - client.clientId, - client.label, - createdAt, - lastUsedAt, - expiresAt, - current, - ) diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionMetrics.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionMetrics.kt deleted file mode 100644 index 07a1ccf..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionMetrics.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.SessionEvent -import com.quantipixels.ogiri.session.SessionEventPublisher -import io.micrometer.core.instrument.MeterRegistry -import java.time.Instant -import java.util.concurrent.atomic.AtomicReference - -/** - * Records session lifecycle events as a low-cardinality Micrometer counter. - * - * Only action and result tags are emitted; subject and session identifiers are deliberately - * omitted. - */ -public class OgiriSessionMetrics(private val registry: MeterRegistry) : SessionEventPublisher { - private val lastEvent = AtomicReference() - - override fun publish(event: SessionEvent) { - registry - .counter( - "ogiri.session.events", - "action", - event.action.name.lowercase(), - "result", - event.reason?.name?.lowercase() ?: "success", - ) - .increment() - lastEvent.set(event.occurredAt) - } - - /** Returns the occurrence time of the most recently observed event. */ - public fun lastEventAt(): Instant? = lastEvent.get() -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionObservabilityAutoConfiguration.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionObservabilityAutoConfiguration.kt deleted file mode 100644 index 42bee36..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionObservabilityAutoConfiguration.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.SessionManager -import org.springframework.boot.actuate.health.Health -import org.springframework.boot.actuate.health.HealthIndicator -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.context.annotation.Bean - -@AutoConfiguration(after = [OgiriSessionAutoConfiguration::class]) -@ConditionalOnClass(HealthIndicator::class) -@ConditionalOnBean(SessionManager::class) -public open class OgiriSessionObservabilityAutoConfiguration { - @Bean("ogiriSessionHealthIndicator") - @ConditionalOnMissingBean(name = ["ogiriSessionHealthIndicator"]) - public open fun ogiriSessionHealthIndicator( - properties: OgiriSessionProperties, - ): HealthIndicator = HealthIndicator { - Health.up() - .withDetail("realm", properties.realm) - .withDetail("transport", properties.transport.name.lowercase()) - .withDetail("cleanupEnabled", properties.cleanup.enabled) - .withDetail("rateLimitEnabled", properties.rateLimit.enabled) - .build() - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionProperties.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionProperties.kt deleted file mode 100644 index 6d32dbf..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionProperties.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import jakarta.validation.Valid -import jakarta.validation.constraints.AssertTrue -import jakarta.validation.constraints.Max -import jakarta.validation.constraints.Min -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Pattern -import java.time.Duration -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.validation.annotation.Validated - -/** HTTP transport used to receive and return session credentials. */ -public enum class OgiriTransport { - BEARER, - COOKIE, - DTA_COMPAT, -} - -/** Supported values for a session cookie's `SameSite` attribute. */ -public enum class OgiriSameSite { - STRICT, - LAX, - NONE, -} - -/** - * Validated configuration for the `ogiri.session` subsystem. - * - * Security-sensitive defaults keep the subsystem and optional endpoints disabled, use strict - * cookies, and require token-hash key material before a default - * [com.quantipixels.ogiri.session.TokenHasher] can be created. - */ -@Validated -@ConfigurationProperties("ogiri.session") -public data class OgiriSessionProperties( - val enabled: Boolean = false, - val realm: String = "users", - val transport: OgiriTransport = OgiriTransport.BEARER, - @field:Min(OpaqueTokenCodec.MIN_CREDENTIAL_CHARS.toLong()) - @field:Max(4096) - val maximumCredentialBytes: Int = 256, - val lifetime: Duration = Duration.ofDays(14), - val previousVersionGrace: Duration = Duration.ofSeconds(5), - @field:Min(1) val maximumActiveSessions: Int = 10, - val evictOldestWhenFull: Boolean = true, - val publicPaths: List = listOf("/auth/sign-in", "/actuator/health"), - @field:Valid val tokenHash: TokenHash = TokenHash(), - @field:Valid val cookie: Cookie = Cookie(), - @field:Valid val endpoints: Endpoints = Endpoints(), - @field:Valid val cleanup: Cleanup = Cleanup(), - @field:Valid val rateLimit: RateLimit = RateLimit(), -) { - init { - require(realm.matches(Regex("[a-z0-9][a-z0-9._-]{0,62}"))) { - "ogiri.session.realm has invalid syntax" - } - require(maximumCredentialBytes >= OpaqueTokenCodec.MIN_CREDENTIAL_CHARS) { - "ogiri.session.maximum-credential-bytes must accept default credentials" - } - require(!lifetime.isNegative && !lifetime.isZero) { "ogiri.session.lifetime must be positive" } - require(!previousVersionGrace.isNegative) { - "ogiri.session.previous-version-grace must not be negative" - } - } - - /** Cookie attributes used when [transport] is [OgiriTransport.COOKIE]. */ - public data class Cookie( - @field:NotBlank - @field:Pattern(regexp = "(?:__Host-)?[!#$%&'*+.^_`|~0-9A-Za-z-]+") - val name: String = "__Host-ogiri-session", - val secure: Boolean = true, - val httpOnly: Boolean = true, - val sameSite: OgiriSameSite = OgiriSameSite.STRICT, - val path: String = "/", - val maxAgeSeconds: Long = 1_209_600, - ) { - @get:AssertTrue(message = "SameSite=None requires Secure=true") - val secureSameSiteNone: Boolean - get() = sameSite != OgiriSameSite.NONE || secure - - @get:AssertTrue(message = "__Host- cookies require Secure=true and Path=/") - val validHostPrefix: Boolean - get() = !name.startsWith("__Host-") || (secure && path == "/") - } - - /** HMAC key ring used to hash credential verifiers; values are Base64-encoded key bytes. */ - public data class TokenHash( - val currentKeyId: String = "", - val keys: Map = emptyMap(), - ) - - /** Scheduling, lease, and page-size settings for expired-session cleanup. */ - public data class Cleanup( - val enabled: Boolean = false, - val interval: Duration = Duration.ofHours(6), - val lease: Duration = Duration.ofMinutes(30), - val maxRunDuration: Duration = Duration.ofMinutes(5), - @field:Min(1) @field:Max(10_000) val batchSize: Int = 500, - ) { - init { - require(!interval.isNegative && !interval.isZero) { - "ogiri.session.cleanup.interval must be positive" - } - require(!lease.isNegative && !lease.isZero) { "ogiri.session.cleanup.lease must be positive" } - require(!maxRunDuration.isNegative && !maxRunDuration.isZero) { - "ogiri.session.cleanup.max-run-duration must be positive" - } - require(maxRunDuration < lease) { - "ogiri.session.cleanup.max-run-duration must be shorter than the lease" - } - } - } - - /** Fixed-window rate-limit settings for the optional sign-in endpoint. */ - public data class RateLimit( - val enabled: Boolean = false, - @field:Min(1) val signInPermits: Long = 10, - val window: Duration = Duration.ofMinutes(1), - @field:NotBlank val keyPrefix: String = "ogiri:rate-limit:", - ) { - init { - require(!window.isNegative && !window.isZero) { - "ogiri.session.rate-limit.window must be positive" - } - } - } - - /** Settings for the optional built-in session-management HTTP endpoints. */ - public data class Endpoints( - val enabled: Boolean = false, - @field:NotBlank val basePath: String = "/auth", - ) { - init { - require(basePath.matches(Regex("/(?:[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*)"))) { - "ogiri.session.endpoints.base-path must be a canonical absolute literal path" - } - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponses.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponses.kt deleted file mode 100644 index a044331..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponses.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.fasterxml.jackson.databind.ObjectMapper -import com.quantipixels.ogiri.session.IssuedSession -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.TokenCodec -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import java.net.URI -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.http.ProblemDetail -import org.springframework.http.ResponseCookie -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.AuthenticationEntryPoint -import org.springframework.security.web.authentication.AuthenticationFailureHandler - -/** - * Writes issued credentials using the configured response transport. - * - * Every response is marked `no-store`; credential values are emitted only in headers or cookies, - * never in an endpoint response body. - */ -public class OgiriSessionResponseWriter( - private val properties: OgiriSessionProperties, - private val codec: TokenCodec = OpaqueTokenCodec(), -) { - /** Writes [issued]'s credential and the response cache controls required for secret material. */ - public fun writeCredential(response: HttpServletResponse, issued: IssuedSession) { - response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store") - response.setHeader(HttpHeaders.PRAGMA, "no-cache") - val encoded = issued.credential.encoded(codec) - when (properties.transport) { - OgiriTransport.BEARER -> response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer $encoded") - OgiriTransport.COOKIE -> - response.addHeader(HttpHeaders.SET_COOKIE, credentialCookie(encoded).toString()) - OgiriTransport.DTA_COMPAT -> { - response.setHeader("access-token", encoded) - response.setHeader("client", issued.session.client.clientId) - response.setHeader("uid", issued.session.subject.subjectId.value) - } - } - } - - /** Expires the configured credential cookie, when applicable, and disables response caching. */ - public fun clearCredential(response: HttpServletResponse) { - response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store") - response.setHeader(HttpHeaders.PRAGMA, "no-cache") - if (properties.transport == OgiriTransport.COOKIE) { - response.addHeader( - HttpHeaders.SET_COOKIE, - credentialCookie("").mutate().maxAge(0).build().toString(), - ) - } - } - - private fun credentialCookie(value: String): ResponseCookie { - val cookie = properties.cookie - return ResponseCookie.from(cookie.name, value) - .httpOnly(cookie.httpOnly) - .secure(cookie.secure) - .path(cookie.path) - .sameSite(cookie.sameSite.name.lowercase().replaceFirstChar(Char::uppercase)) - .maxAge(cookie.maxAgeSeconds) - .build() - } -} - -/** - * Renders authentication failures as safe RFC 9457 problem details. - * - * Exception messages are exposed only when they match the restricted machine-code grammar. - */ -public class OgiriProblemAuthenticationEntryPoint(private val mapper: ObjectMapper) : - AuthenticationEntryPoint, AuthenticationFailureHandler { - override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: AuthenticationException, - ) { - write(request, response, authException) - } - - override fun onAuthenticationFailure( - request: HttpServletRequest, - response: HttpServletResponse, - exception: AuthenticationException, - ) { - write(request, response, exception) - } - - private fun write( - request: HttpServletRequest, - response: HttpServletResponse, - exception: AuthenticationException, - ) { - val code = exception.message?.takeIf { it.matches(SAFE_CODE) } ?: "invalid_credential" - val problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Authentication failed") - problem.type = URI.create("https://quantipixels.com/problems/$code") - problem.title = "Unauthorized" - problem.instance = URI.create(request.requestURI) - problem.setProperty("code", code) - response.status = HttpStatus.UNAUTHORIZED.value() - response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE - response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer error=\"invalid_token\"") - response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store") - response.setHeader(HttpHeaders.PRAGMA, "no-cache") - mapper.writeValue(response.outputStream, problem) - } - - private companion object { - private val SAFE_CODE = Regex("[a-z][a-z0-9_]{0,63}") - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriAuditHook.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriAuditHook.kt deleted file mode 100644 index e676f31..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriAuditHook.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -/** - * No-op implementation of [OgiriAuditHook] used as the default when no audit hook bean is present. - * - * All interface methods have Kotlin default bodies (empty), so this object body is intentionally - * empty. Follows the Null-Object pattern used throughout Spring Security (e.g., - * `NoOpPasswordEncoder`). - * - * **Usage in tests:** prefer referencing this object directly rather than implementing an anonymous - * `object : OgiriAuditHook {}`, so the null-object pattern is visible and consistent. - */ -object NoOpOgiriAuditHook : OgiriAuditHook diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriRateLimitHook.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriRateLimitHook.kt deleted file mode 100644 index ad7f04b..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/NoOpOgiriRateLimitHook.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -/** - * No-op implementation of [OgiriRateLimitHook] used as the default when no rate-limit hook bean is - * present. - * - * All interface methods have Kotlin default bodies (empty), so this object body is intentionally - * empty. Follows the Null-Object pattern used throughout Spring Security (e.g., - * `NoOpPasswordEncoder`). - * - * **Usage in tests:** prefer referencing this object directly rather than implementing an anonymous - * `object : OgiriRateLimitHook {}`, so the null-object pattern is visible and consistent. - */ -object NoOpOgiriRateLimitHook : OgiriRateLimitHook diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriAuditHook.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriAuditHook.kt deleted file mode 100644 index 24adcb4..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriAuditHook.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -/** - * Audit hook for security-significant events. - * - * Consumers implement this interface to integrate with their logging/SIEM systems (e.g., Splunk, - * ELK, CloudWatch). The default implementation is a no-op. - */ -interface OgiriAuditHook { - /** - * Called after a successful login. - * - * @param userId The authenticated user's ID. - * @param client The client ID of the newly issued token. - * @param ip The remote IP address, or `null` if unavailable. - */ - fun onLoginSuccess(userId: Long, client: String, ip: String?) {} - - /** - * Called when a login attempt fails. - * - * @param identifier The email or username supplied in the attempt. - * @param reason A short machine-readable string describing the failure (e.g., `"user_not_found"`, - * `"invalid_password"`). - * @param ip The remote IP address, or `null` if unavailable. - */ - fun onLoginFailure(identifier: String, reason: String, ip: String?) {} - - /** - * Called after an APP token is rotated for a user/client. - * - * @param userId The user whose token was rotated. - * @param client The client ID of the rotated token. - */ - fun onTokenRotated(userId: Long, client: String) {} - - /** - * Called after an APP token is explicitly revoked. - * - * @param userId The user whose token was revoked. - * @param client The client ID of the revoked token. - */ - fun onTokenRevoked(userId: Long, client: String) {} - - /** - * Called after a new sub-token is issued for a parent client. - * - * @param userId The user the sub-token belongs to. - * @param parentClient The client ID of the parent APP token. - * @param subTokenName The registration name of the sub-token (e.g., `"device"`, `"chat"`). - */ - fun onSubTokenCreated(userId: Long, parentClient: String, subTokenName: String) {} - - /** - * Called after all sub-tokens of a given type are revoked for a user. - * - * @param userId The user whose sub-tokens were revoked. - * @param subTokenName The registration name of the revoked sub-token type (e.g., `"device"`, - * `"chat"`). - */ - fun onSubTokenRevoked(userId: Long, subTokenName: String) {} -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriCacheKey.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriCacheKey.kt deleted file mode 100644 index 884cdc5..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriCacheKey.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -/** - * Cache key helpers shared by all [OgiriTokenLookupCache] implementations. - * - * All implementations (Caffeine, Redis, Spring CacheManager) must use the same key format to ensure - * consistency in eviction logic and observability tooling. The namespaced prefix (`ogiri:token:`) - * prevents collisions when the backing store is shared with other tenants. - * - * **Key format:** `ogiri:token:{userId}:{client}` - */ -object OgiriCacheKey { - fun key(userId: Long, client: String) = "ogiri:token:$userId:$client" - - fun prefix(userId: Long) = "ogiri:token:$userId:" -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriRateLimitHook.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriRateLimitHook.kt deleted file mode 100644 index f86b354..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriRateLimitHook.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -import jakarta.servlet.http.HttpServletRequest - -/** - * Rate limiting hook for authentication endpoints. - * - * Consumers implement this to enforce rate limits using their preferred strategy (e.g., Bucket4j, - * Redis sliding window, in-memory token bucket). The default implementation allows all requests (no - * rate limiting). - * - * Throw [com.quantipixels.ogiri.security.core.SecurityServiceException] with code - * "error.auth.rate_limited" to reject the request. - */ -interface OgiriRateLimitHook { - /** - * Called before a login attempt is processed. - * - * @param request The current HTTP request (for IP, headers, etc.). - * @param identifier The email or username provided by the caller. - * @throws [com.quantipixels.ogiri.security.core.SecurityServiceException] with code - * `"error.auth.rate_limited"` to reject the request. - */ - fun beforeLogin(request: HttpServletRequest, identifier: String) {} - - /** - * Called before a new token is issued for a user. - * - * @param request The current HTTP request. - * @param userId The ID of the user requesting a new token. - * @throws [com.quantipixels.ogiri.security.core.SecurityServiceException] with code - * `"error.auth.rate_limited"` to reject the request. - */ - fun beforeTokenCreation(request: HttpServletRequest, userId: Long) {} - - /** - * Called before an existing sub-token is renewed for a user. - * - * Distinct from [beforeTokenCreation]: renewal replaces an existing sub-token rather than - * creating a fresh session. Implement to enforce sub-token rotation rate limits independently of - * APP token creation limits. - * - * @param request The current HTTP request (for IP, headers, etc.). - * @param userId The ID of the user requesting the sub-token renewal. - * @throws [com.quantipixels.ogiri.security.core.SecurityServiceException] with code - * `"error.auth.rate_limited"` to reject the request. - */ - fun beforeSubTokenRenewal(request: HttpServletRequest, userId: Long) {} -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriSpringCacheAdapter.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriSpringCacheAdapter.kt deleted file mode 100644 index 602ff55..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriSpringCacheAdapter.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -import com.quantipixels.ogiri.security.tokens.OgiriToken -import org.slf4j.LoggerFactory -import org.springframework.cache.Cache -import org.springframework.cache.CacheManager - -/** - * Bridges an existing Spring [CacheManager] into the [OgiriTokenLookupCache] SPI. - * - * Activate by setting `ogiri.cache.use-spring-cache-manager=true` in your application - * configuration. Ogiri will detect the existing [CacheManager] bean and wrap it automatically — no - * additional dependency is required beyond your existing cache provider. - * - * ## evictAll limitation - * - * Spring's [org.springframework.cache.Cache] interface only supports key-level eviction. [evictAll] - * logs a `WARN` and is a no-op on this tier; stale entries expire via the configured TTL. This is - * safe because token validation (BCrypt comparison and expiry check) still runs on every request - * regardless of cache state. - * - * Consumers who require immediate user-wide eviction (e.g., forced logout across all devices) - * should provide a custom [OgiriTokenLookupCache] bean (Tier 1) backed by a `RedisTemplate` - * SCAN/UNLINK or equivalent pattern-based eviction. - * - * ## Recommended configuration - * - * ```yaml - * spring: - * cache: - * type: redis - * cache-names: ogiri-token-lookup - * redis: - * time-to-live: 300000 # 5 minutes - * - * ogiri: - * cache: - * use-spring-cache-manager: true - * cache-name: ogiri-token-lookup - * ``` - * - * @param cacheManager The Spring [CacheManager] to wrap. - * @param cacheName The name of the cache to use; must be listed in `spring.cache.cache-names`. - */ -class OgiriSpringCacheAdapter( - cacheManager: CacheManager, - private val cacheName: String, -) : OgiriTokenLookupCache { - - private val cache: Cache = - cacheManager.getCache(cacheName) - ?: throw IllegalStateException( - "Cache '$cacheName' not found in CacheManager. " + - "Ensure spring.cache.cache-names includes '$cacheName' " + - "or set ogiri.cache.cache-name to a cache that exists.") - - override fun get(userId: Long, client: String): T? = - @Suppress("UNCHECKED_CAST") cache.get(OgiriCacheKey.key(userId, client))?.get() as? T - - override fun put(userId: Long, client: String, token: T) = - cache.put(OgiriCacheKey.key(userId, client), token) - - override fun evict(userId: Long, client: String) { - cache.evict(OgiriCacheKey.key(userId, client)) - } - - override fun evictAll(userId: Long) { - logger.warn( - "evictAll(userId={}) is not supported on the Spring CacheManager tier. " + - "Stale entries will expire via the configured TTL. " + - "To support immediate user-wide eviction, provide a custom OgiriTokenLookupCache bean.", - userId) - } - - companion object { - private val logger = LoggerFactory.getLogger(OgiriSpringCacheAdapter::class.java) - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriTokenLookupCache.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriTokenLookupCache.kt deleted file mode 100644 index d82ed7e..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriTokenLookupCache.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -import com.quantipixels.ogiri.security.tokens.OgiriToken - -/** - * Optional SPI for caching token entity lookups. - * - * When a bean implementing this interface is present in the application context, - * [com.quantipixels.ogiri.security.tokens.OgiriTokenService] checks it before hitting the - * repository on every read. Implementations own their eviction strategy. - * - * When no bean is present the service falls through to the repository directly — zero behavior - * change for existing consumers. - * - * ## Key format - * - * All implementations must use [OgiriCacheKey] to produce cache keys so that eviction logic and - * observability tooling are consistent. The canonical key format is - * `ogiri:token:{userId}:{client}`. - * - * ## evictAll contract - * - * Implementations backed by a store that does not support pattern-based eviction (e.g., the Spring - * [org.springframework.cache.CacheManager] tier) may implement [evictAll] as a no-op. In that case - * stale entries expire via the configured TTL — token validation (BCrypt + expiry check) still runs - * on every request regardless of cache state, so this is safe for correctness. Document the no-op - * behaviour in the implementation's KDoc so consumers can make an informed choice. - * - * Example (Caffeine): - * ```kotlin - * @Component - * class CaffeineTokenLookupCache : OgiriTokenLookupCache { - * private val cache = Caffeine.newBuilder() - * .maximumSize(10_000) - * .expireAfterWrite(Duration.ofMinutes(5)) - * .build() - * - * override fun get(userId: Long, client: String) = - * cache.getIfPresent(OgiriCacheKey.key(userId, client)) - * override fun put(userId: Long, client: String, token: UserToken) = - * cache.put(OgiriCacheKey.key(userId, client), token) - * override fun evict(userId: Long, client: String) = - * cache.invalidate(OgiriCacheKey.key(userId, client)) - * override fun evictAll(userId: Long) = - * cache.invalidateAll(cache.asMap().keys.filter { it.startsWith(OgiriCacheKey.prefix(userId)) }) - * } - * ``` - */ -interface OgiriTokenLookupCache { - /** Return a cached token entity, or null on cache miss. */ - fun get(userId: Long, client: String): T? - - /** Store a token entity in the cache. */ - fun put(userId: Long, client: String, token: T) - - /** Remove the cache entry for a specific user/client combination. */ - fun evict(userId: Long, client: String) - - /** - * Remove all cache entries for a user (e.g., on full revocation). - * - * Implementations that cannot support pattern-based eviction may log a warning and return without - * removing entries; callers must not rely on immediate eviction from this tier. - */ - fun evictAll(userId: Long) -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUser.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUser.kt deleted file mode 100644 index f77017e..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUser.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -import org.springframework.security.core.userdetails.UserDetails - -interface OgiriUser : UserDetails { - /** - * Get the user's Ogiri identifier. - * - * @return The user's Ogiri identifier as a Long. - */ - fun getOgiriUserId(): Long -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUserDirectory.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUserDirectory.kt deleted file mode 100644 index 11293a4..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/spi/OgiriUserDirectory.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.spi - -import org.springframework.security.core.userdetails.UserDetailsService - -interface OgiriUserDirectory : UserDetailsService { - /** - * Retrieves an OgiriUser by their numeric identifier. - * - * @param id The user's numeric ID. - * @return The matching `OgiriUser` if found, `null` otherwise. - */ - fun findById(id: Long): OgiriUser? - - /** - * Finds an OgiriUser by their email address. - * - * Returns `null` when the user is not found instead of throwing an exception. - * - * @param email The email address to look up. - * @return `OgiriUser` if a user with the given email exists, `null` otherwise. - */ - fun findByEmail(email: String): OgiriUser? - - /** - * Finds an OgiriUser by their username. - * - * Unlike [loadUserByUsername] from [UserDetailsService], this method returns `null` when the user - * is not found instead of throwing an exception. - * - * @param username The username to look up. - * @return `OgiriUser` if a user with the given username exists, `null` otherwise. - */ - fun findByUsername(username: String): OgiriUser? - - /** - * Records a successful login for the specified user. - * - * @param userId The numeric identifier of the user whose successful login should be recorded. - */ - fun recordSuccessfulLogin(userId: Long) -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriSubTokenRegistry.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriSubTokenRegistry.kt deleted file mode 100644 index 81422d0..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriSubTokenRegistry.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -/** - * Simple registry implementation backed by a provided list of [OgiriSubTokenRegistration] beans. - */ -class DefaultOgiriSubTokenRegistry( - private val registrations: List = emptyList(), -) : OgiriSubTokenRegistry { - override fun registrations(): List = registrations -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriTokenServiceResolver.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriTokenServiceResolver.kt deleted file mode 100644 index 16b0860..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/DefaultOgiriTokenServiceResolver.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory - -class DefaultOgiriTokenServiceResolver( - private val tokenServices: Map>, - private val properties: OgiriConfigurationProperties, - private val beanFactory: ConfigurableListableBeanFactory, -) : OgiriTokenServiceResolver { - /** - * Resolve the single OgiriTokenService bean to use for token operations. - * - * Selects and returns one OgiriTokenService from the registered beans, preferring a single - * registered bean or a single bean marked `@Primary`. If no service is available or multiple - * candidates exist without a single primary, resolution fails. - * - * @return The selected OgiriTokenService instance. - * @throws IllegalStateException if no OgiriTokenService beans are found or if multiple beans are - * found without a single `@Primary` candidate. - */ - override fun resolve(): OgiriTokenService<*> { - if (tokenServices.isEmpty()) { - val tokenServiceIsEnabled = properties.auth.registerTokenService - if (tokenServiceIsEnabled) { - error( - "No OgiriTokenService bean found despite ogiri.auth.register-token-service=true. " + - "Ensure a valid OgiriTokenRepository bean exists, or provide a custom OgiriTokenService implementation.") - } - error( - "No OgiriTokenService bean found. Provide a custom OgiriTokenService (default creation disabled via ogiri.auth.register-token-service=false).") - } - - if (tokenServices.size == 1) { - return tokenServices.values.first() - } - - val primaryBeanNames = - tokenServices.keys - .filter { beanName -> - beanFactory.containsBeanDefinition(beanName) && - beanFactory.getBeanDefinition(beanName).isPrimary - } - .sorted() - if (primaryBeanNames.size == 1) { - return tokenServices.getValue(primaryBeanNames.single()) - } - - error( - "Multiple OgiriTokenService beans found (${tokenServices.keys.sorted().joinToString()}): mark one @Primary or inject by @Qualifier.") - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriBaseToken.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriBaseToken.kt deleted file mode 100644 index b735422..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriBaseToken.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import java.time.Instant - -/** - * Convenience abstract base class implementing [OgiriToken]. - * - * This abstract class provides a convenient base implementation with sensible defaults for optional - * properties. Users who prefer class inheritance can extend this class. Users who need more - * flexibility (e.g., to extend their own base class) can implement [OgiriToken] directly. - * - * Example - JPA Implementation: - * ```kotlin - * @Entity - * @Table(name = "user_tokens") - * data class JpaToken( - * @Id @GeneratedValue override val id: Long = 0, - * @Column(name = "user_id") override val userId: Long, - * @Column(name = "client") override val client: String, - * @Column(name = "token_hash") override var token: String, - * // ... remaining fields - * ) : OgiriBaseToken() - * ``` - * - * Example - JDBC/Plain Kotlin: - * ```kotlin - * data class JdbcToken( - * override val id: Long = 0, - * override val userId: Long, - * override val client: String, - * override var token: String, - * // ... remaining fields - * ) : OgiriBaseToken() - * ``` - */ -abstract class OgiriBaseToken : OgiriToken { - /** Unique token identifier (primary key). Database auto-increment recommended. */ - abstract override var id: Long - - /** User identifier associated with this token. Should be indexed for efficient lookups. */ - abstract override val userId: Long - - /** - * Client/application identifier. Combined with userId for unique constraint. Should be indexed. - */ - abstract override val client: String - - /** - * Hashed token value (never plaintext). Use BCrypt or similar hashing algorithm. Always stored in - * database. - */ - abstract override var token: String - - /** - * Token type classifier. Default: "app" for primary tokens. Custom: "device", "chat", etc. for - * sub-tokens. - */ - abstract override val tokenType: String - - /** - * Token expiration timestamp (UTC). Should be indexed for efficient cleanup of expired tokens. - */ - abstract override var expiryAt: Instant - - /** - * Timestamp when token was created. Usually auto-populated (Instant.now() or database DEFAULT). - * Not updatable after creation. - */ - abstract override val createdAt: Instant - - /** Timestamp when token was last updated. Usually auto-updated on any row modification. */ - abstract override var updatedAt: Instant - - /** Timestamp when token rotation last occurred. Used for token rotation policy decisions. */ - abstract override var tokenUpdatedAt: Instant - - /** - * Optional sub-token identifier. Used to distinguish different token types within same token - * record. Example: "device", "chat", "api" - */ - override var tokenSubtype: String? = null - - /** - * Previous token hash (for grace period during rotation). Allows brief window where old token - * still works while new token is issued. - */ - override var lastToken: String? = null - - /** - * Token before last (extended grace period). For additional safety during token rotation cascade. - */ - override var previousToken: String? = null - - /** - * Last timestamp this token was successfully used for authentication. Useful for monitoring and - * cleanup of stale tokens. - */ - override var lastUsedAt: Instant? = null - - /** - * Plain (unhashed) token value. NEVER persisted to database. NEVER logged. Only exists in-memory - * temporarily during token creation. Sent to client for authentication headers. - */ - override var plainToken: String? = null - - /** Compact single-line representation including id, userId, client, tokenType, and expiryAt. */ - override fun toString(): String = - "Token(id=$id, userId=$userId, client=$client, tokenType=$tokenType, expiryAt=$expiryAt)" -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistration.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistration.kt deleted file mode 100644 index 47d97b5..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistration.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import java.time.Instant - -/** - * Describes a sub-token that should be issued alongside an APP token. - * - * Implementations can plug into [OgiriSubTokenRegistry] to declare additional token kinds (e.g., - * device-scoped tokens, chat credentials). The contract is intentionally small: name, client id - * mapping, expiry policy, and whether creation should be forced on every issuance. - * - * Implementations may also provide custom validation logic via [validate] for format-specific - * checks (e.g., service token signature verification, device fingerprint validation). - */ -interface OgiriSubTokenRegistration { - /** Unique name for the sub-token (e.g., "device", "chat"). */ - val name: String - - /** Whether this sub-token should be created when no explicit list is provided. */ - val includeByDefault: Boolean - get() = true - - /** - * Compute the client id for this sub-token based on the parent APP client id. - * - * @param parentClientId The parent APP client id used to derive the sub-token's client id. - * @return The computed client id for the sub-token. - */ - fun clientIdFor(parentClientId: String): String - - /** - * Determine the expiry Instant for this sub-token based on the parent APP expiry. - * - * The returned expiry will not be later than [parentExpiry]. - * - * @param parentExpiry The expiry Instant of the parent APP token. - * @return The expiry Instant for the sub-token (no later than [parentExpiry]). - */ - fun expiry(parentExpiry: Instant): Instant - - /** Whether issuing this sub-token should always rotate (overwrite). */ - val forceNew: Boolean - get() = false - - /** - * Performs additional validation of a sub-token after its hash has been verified. - * - * @param plainToken The raw (unhashed) token value to validate. - * @return `true` if the token passes custom validation, `false` otherwise. Default implementation - * returns `true`. - */ - fun validate(plainToken: String): Boolean = true -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistry.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistry.kt deleted file mode 100644 index 713300a..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriSubTokenRegistry.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -interface OgiriSubTokenRegistry { - /** - * Retrieve the registered sub-token registrations. - * - * @return A `List` containing all registrations in registration order. - */ - fun registrations(): List -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriToken.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriToken.kt deleted file mode 100644 index ebf093c..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriToken.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import java.time.Instant - -/** - * Core interface for all Ogiri token implementations. - * - * This interface defines the contract for token entities without imposing implementation - * constraints. Users can: - * - Implement this interface directly for maximum flexibility - * - Extend [OgiriBaseToken] for a convenience implementation with sensible defaults - * - * Example - Direct Interface Implementation: - * ```kotlin - * @Entity - * @Table(name = "my_tokens") - * data class MyToken( - * @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - * override val id: Long = 0, - * @Column(name = "user_id") override val userId: Long, - * @Column(name = "client") override val client: String, - * @Column(name = "token_hash") override var token: String, - * @Column(name = "token_type") override val tokenType: String = "app", - * @Column(name = "expiry_at") override var expiryAt: Instant, - * @CreationTimestamp override val createdAt: Instant = Instant.now(), - * @UpdateTimestamp override val updatedAt: Instant = Instant.now(), - * @Column(name = "token_updated_at") override var tokenUpdatedAt: Instant = Instant.now(), - * @Column(name = "token_subtype") override var tokenSubtype: String? = null, - * @Column(name = "last_token_hash") override var lastToken: String? = null, - * @Column(name = "previous_token_hash") override var previousToken: String? = null, - * @Column(name = "last_used_at") override var lastUsedAt: Instant? = null, - * // Custom fields for your application - * @Column(name = "metadata") var metadata: String? = null, - * ) : OgiriToken { - * @Transient - * override var plainToken: String? = null - * } - * ``` - * - * Example - Using Base Class: - * ```kotlin - * @Entity - * data class MyToken( - * override val id: Long = 0, - * override val userId: Long, - * // ... remaining fields - * ) : OgiriBaseToken() - * ``` - */ -interface OgiriToken { - /** Unique token identifier (primary key). Database auto-increment recommended. */ - val id: Long - - /** User identifier associated with this token. Should be indexed for efficient lookups. */ - val userId: Long - - /** - * Client/application identifier. Combined with userId for unique constraint. Should be indexed. - * - * The (userId, client) pair is the primary lookup key — each user has at most one token per - * client. This means writes are single-writer per key: no two writers can race for the same - * user/client record. - */ - val client: String - - /** - * Hashed token value (never plaintext). Use BCrypt or similar hashing algorithm. Always stored in - * database. - */ - var token: String - - /** - * Token type classifier. Default: "app" for primary tokens. Use "sub" for specialized tokens - * (device, chat, etc.). - */ - val tokenType: String - - /** - * Token expiration timestamp (UTC). Should be indexed for efficient cleanup of expired tokens. - */ - var expiryAt: Instant - - /** - * Timestamp when token was created. Usually auto-populated (Instant.now() or database DEFAULT). - * Not updatable after creation. - */ - val createdAt: Instant - - /** Timestamp when token was last updated. Usually auto-updated on any row modification. */ - val updatedAt: Instant - - /** Timestamp when token rotation last occurred. Used for token rotation policy decisions. */ - var tokenUpdatedAt: Instant - - /** - * Optional sub-token identifier. Used to distinguish different token types within same token - * record. Example: "device", "chat", "api" - */ - var tokenSubtype: String? - - /** - * Previous token hash (for grace period during rotation). Allows brief window where old token - * still works while new token is issued. - */ - var lastToken: String? - - /** - * Token before last (extended grace period). For additional safety during token rotation cascade. - */ - var previousToken: String? - - /** - * Last timestamp this token was successfully used for authentication. Useful for monitoring and - * cleanup of stale tokens. - */ - var lastUsedAt: Instant? - - /** - * Plain (unhashed) token value. NEVER persisted to database. NEVER logged. Only exists in-memory - * temporarily during token creation. Sent to client for authentication headers. - * - * Implementations using JPA/Hibernate should mark this with @Transient. - */ - var plainToken: String? - - /** - * Determines whether the token is expired relative to the provided instant. - * - * @param now Instant to compare the token's expiry against; defaults to the current instant. - * @return `true` if `expiryAt` is before `now`, `false` otherwise. - */ - fun isExpired(now: Instant = Instant.now()): Boolean = expiryAt.isBefore(now) -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenCleanupJob.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenCleanupJob.kt deleted file mode 100644 index d6f47e1..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenCleanupJob.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import java.time.Instant -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled - -class OgiriTokenCleanupJob( - private val tokenServiceResolver: OgiriTokenServiceResolver, -) { - private val logger = LoggerFactory.getLogger(OgiriTokenCleanupJob::class.java) - - /** - * Removes expired user tokens from the resolved token service. - * - * Uses batched deletion to avoid overwhelming the database with large DELETE operations. Tokens - * are deleted in configurable batches (see ogiri.cleanup.batch-size). - * - * If any tokens are removed, logs the count at info level. - */ - @Scheduled(fixedDelayString = "\${ogiri.cleanup.interval-ms:21600000}") - fun cleanupExpiredTokens() { - try { - val deleted = tokenServiceResolver.resolve().cleanupExpiredTokensBatched(Instant.now()) - if (deleted > 0) { - logger.info("Expired user tokens removed count={}", deleted) - } - } catch (e: Exception) { - logger.error("Token cleanup failed", e) - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenExtensions.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenExtensions.kt deleted file mode 100644 index f672166..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenExtensions.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -/** Filter the collection to tokens that match the specified OgiriTokenType. */ -fun Collection.filterByOgiriTokenType(type: OgiriTokenType): List = filter { - OgiriTokenType.of(it.tokenType) == type -} - -/** Filters the collection to tokens of type APP. */ -fun Collection.appTokens(): List = filterByOgiriTokenType(OgiriTokenType.APP) - -/** Return tokens whose OgiriTokenType is SUB. */ -fun Collection.subTokens(): List = filterByOgiriTokenType(OgiriTokenType.SUB) - -/** Collects unique client IDs from the tokens in this collection. */ -fun Collection.clientIds(): Set = map { it.client }.toSet() - -/** Filters tokens whose `client` value is contained in the provided set. */ -fun Collection.filterByClientIds(clientIds: Set): List = filter { - it.client in clientIds -} - -/** Excludes tokens whose client is in the provided set. */ -fun Collection.filterOutClientIds(clientIds: Set): List = filter { - it.client !in clientIds -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenRepository.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenRepository.kt deleted file mode 100644 index 1bd0d5d..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenRepository.kt +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import java.time.Instant -import java.util.Optional -import org.springframework.data.repository.NoRepositoryBean - -/** - * Repository interface for token persistence. - * - * Method names follow Spring Data naming conventions for automatic query generation. Extend this - * interface along with your preferred Spring Data repository (JpaRepository, CrudRepository, etc.) - * and Spring Data will generate all implementations automatically. - * - * ## Example - Direct Spring Data Integration (Recommended): - * ```kotlin - * @Repository - * interface MyTokenRepository : JpaRepository, OgiriTokenRepository - * ``` - * - * When extending Spring Data repositories, the standard CRUD operations (`save`, `findById`, - * `delete`, `deleteById`) are provided by Spring Data. This interface only declares the - * token-specific query methods that Spring Data can auto-generate from method names. - * - * ## Example - Custom Implementation (non-Spring Data): - * ```kotlin - * @Repository - * class JdbcTokenRepository(private val jdbcTemplate: JdbcTemplate) : OgiriTokenRepository { - * // Implement CRUD methods - * override fun save(token: JdbcToken): JdbcToken { /* INSERT or UPDATE logic */ } - * override fun findById(id: Long): Optional { /* ... */ } - * override fun delete(token: JdbcToken) { /* ... */ } - * override fun deleteById(id: Long) { /* ... */ } - * // ... implement query methods - * } - * ``` - * - * All methods must handle null/empty return values appropriately for the implementation. - * Implementers should ensure: - * - Thread-safety for concurrent access - * - Transaction semantics if applicable to the storage backend - * - Proper error handling for storage failures - */ -@NoRepositoryBean -interface OgiriTokenRepository { - - /** - * Persist a token entity, inserting or updating as appropriate. - * - * @param token The token to persist. - * @return The saved token; for newly inserted tokens this includes the generated ID. - */ - fun save(token: S): S - - /** - * Retrieve a token by its primary key ID. - * - * @param id The token ID - * @return Optional containing the token if found, empty otherwise - */ - fun findById(id: Long): Optional - - /** - * Delete a token by its primary key ID. - * - * @param id The token ID to delete - */ - fun deleteById(id: Long) - - /** - * Delete the given token. - * - * @param token The token to delete. - */ - fun delete(token: T) - - /** - * Find all tokens for a user, ordered by most recently updated first. - * - * Used for listing user sessions and cleanup operations. - * - * @param userId The ID of the user whose tokens to retrieve. - * @return List of tokens for the user; empty list if none are found. - */ - fun findByUserIdOrderByUpdatedAtDesc(userId: Long): List - - /** - * Find a specific token by user and client. - * - * Used for session lookup and token rotation. - * - * @param userId The user's primary key ID. - * @param client The client/application identifier. - * @return Optional containing the token if found, empty otherwise. - */ - fun findByUserIdAndClient( - userId: Long, - client: String, - ): Optional - - /** - * Find tokens for a user matching any of the given clients. - * - * Used for batch loading sub-tokens to avoid N+1 queries. Spring Data will auto-generate this - * query from the method name. - * - * @param userId The user's primary key ID. - * @param clients Collection of client identifiers to match. - * @return List of tokens matching any of the clients; empty list if none found. - */ - fun findByUserIdAndClientIn( - userId: Long, - clients: Collection, - ): List - - /** - * Find tokens for a user filtered by subtype (e.g., "device", "api"), ordered by most recently - * updated first. - * - * Used for sub-token management. - * - * @param userId The ID of the user whose tokens to retrieve. - * @param tokenSubtype Sub-token type identifier (for example `"device"` or `"chat"`). - * @return List of matching tokens; an empty list when none are found. - */ - fun findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc( - userId: Long, - tokenSubtype: String, - ): List - - /** - * Find all tokens that expired before the cutoff. - * - * Used by cleanup job to identify expired tokens. - * - * @param cutoff Instant used as the expiry threshold; tokens with an expiry time before this - * Instant are returned. - * @return List of tokens that expired before the cutoff, or an empty list if none are found. - */ - fun findByExpiryAtBefore(cutoff: Instant): List - - /** - * Find at most [limit] tokens that expired before [cutoff], ordered by expiry time ascending. - * - * Used by [com.quantipixels.ogiri.security.tokens.OgiriTokenService.cleanupExpiredTokensBatched] - * to avoid loading the full set of expired tokens on every batch iteration. Implementations - * backed by a relational database should override this with a native `LIMIT` clause for - * efficiency; the default delegates to [findByExpiryAtBefore] and trims in memory. - * - * Example override (Spring Data JPA): - * ```kotlin - * fun fetchTopExpiredBefore(cutoff: Instant, limit: Int): List = - * entityManager.createQuery( - * "SELECT t FROM Token t WHERE t.expiryAt < :cutoff ORDER BY t.expiryAt", Token::class.java - * ).setParameter("cutoff", cutoff).setMaxResults(limit).resultList - * ``` - * - * @param cutoff Instant used as the expiry threshold. - * @param limit Maximum number of tokens to return. - * @return Up to [limit] tokens ordered by ascending expiry, or an empty list if none are found. - */ - fun fetchTopExpiredBefore(cutoff: Instant, limit: Int): List = - findByExpiryAtBefore(cutoff).take(limit) - - /** - * Find all tokens of a specific type (e.g., "app", "sub"). - * - * Used for prefix-less token lookups. - * - * @param tokenType The token type to filter by (e.g., "app", "sub") - * @return List of tokens matching the type - */ - fun findByTokenType(tokenType: String): List - - /** - * Delete a specific user's token for a client. - * - * Used for single-device logout. - * - * @param userId The user ID - * @param client The client/application identifier - */ - fun deleteByUserIdAndClient( - userId: Long, - client: String, - ) - - /** - * Delete multiple tokens for a user by client IDs. - * - * Used for bulk session revocation (e.g., revoke all devices except the current one). - * - * @param userId The user's primary key identifier. - * @param clients Collection of client identifiers whose tokens should be removed for the user. - */ - fun deleteByUserIdAndClientIn( - userId: Long, - clients: Collection, - ) - - /** - * Delete all tokens for a user. - * - * Used for global logout and account deletion. - * - * @param userId The user ID - */ - fun deleteByUserId(userId: Long) - - /** - * Count tokens for a user. - * - * Spring Data auto-generates a COUNT query from the method name. For custom implementations, - * override with an efficient COUNT query: - * ```kotlin - * @Query("SELECT COUNT(t) FROM Token t WHERE t.userId = :userId") - * override fun countByUserId(userId: Long): Long - * ``` - * - * @param userId The user ID to count tokens for - * @return Number of tokens belonging to the user - */ - fun countByUserId(userId: Long): Long - - /** - * Delete tokens by their primary key IDs. - * - * Used for batch cleanup to issue a single bulk `DELETE WHERE id IN (...)` instead of N - * individual statements. The default falls back to per-entity deletion; database-backed - * implementations should override for efficiency. - * - * @param ids Collection of token primary key IDs to delete. - */ - fun deleteByIdIn(ids: Collection) { - ids.forEach { deleteById(it) } - } - - /** - * Delete all expired tokens before cutoff. - * - * Spring Data auto-generates a DELETE query from the method name. For custom implementations, - * override with a bulk DELETE for performance: - * ```kotlin - * @Modifying - * @Query("DELETE FROM Token t WHERE t.expiryAt < :cutoff") - * override fun deleteByExpiryAtBefore(cutoff: Instant): Int - * ``` - * - * @param cutoff Instant used as the expiry threshold; tokens expiring before this are deleted. - * @return Number of tokens deleted. - */ - fun deleteByExpiryAtBefore(cutoff: Instant): Int -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt deleted file mode 100644 index 5ad9813..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenService.kt +++ /dev/null @@ -1,1009 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.github.benmanes.caffeine.cache.Cache -import com.github.benmanes.caffeine.cache.Caffeine -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.AuthHeader -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.core.OgiriService -import com.quantipixels.ogiri.security.core.SecurityServiceException -import com.quantipixels.ogiri.security.core.SubTokenHeader -import com.quantipixels.ogiri.security.core.appendAuthHeaders -import com.quantipixels.ogiri.security.core.extractAuthHeader -import com.quantipixels.ogiri.security.spi.NoOpOgiriAuditHook -import com.quantipixels.ogiri.security.spi.NoOpOgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.spi.OgiriUser -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.concurrent.TimeUnit -import org.slf4j.LoggerFactory -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.context.request.RequestContextHolder -import org.springframework.web.context.request.ServletRequestAttributes - -/** - * Threshold ratio for triggering token cleanup. - * - * Token cleanup is only performed when the number of tokens reaches this percentage of maxClients. - * This prevents unnecessary database operations when users have few active tokens. - */ -const val CLEANUP_THRESHOLD_RATIO = 0.8 - -/** - * Result of a token creation operation. - * - * @property appToken The newly issued APP token. [OgiriToken.plainToken] is populated here and - * should be sent to the client; it is `null` after a round-trip from the database. - * @property subTokens Map of sub-token registration name → issued sub-token. Empty when no - * sub-token registrations are active. Keys match [OgiriSubTokenRegistration.name], not client - * IDs. - */ -data class OgiriGeneratedTokens( - val appToken: T, - val subTokens: Map, -) - -/** - * Core token orchestration: issues/rotates APP tokens, manages pluggable sub-tokens, and validates - * tokens for authentication and downstream protocols. - * - * This service works with any [OgiriToken] implementation. Extend this class and override - * [tokenFactory] to instantiate your custom token class. Inject the six required collaborators; - * optional extension points are passed directly via the constructor or wired by the - * auto-configuration. - * - * ```kotlin - * @Service - * class MyTokenService( - * repo: OgiriTokenRepository, - * passwordEncoder: PasswordEncoder, - * userDirectory: OgiriUserDirectory, - * identifierPolicy: IdentifierPolicy, - * subTokenRegistry: OgiriSubTokenRegistry, - * properties: OgiriConfigurationProperties, - * ) : OgiriTokenService(repo, passwordEncoder, userDirectory, - * identifierPolicy, subTokenRegistry, properties) { - * override fun tokenFactory(...): MyToken = MyToken(...) - * } - * ``` - * - * Optional extension points (all default to no-op / null): - * - Call [setAuditHook] to receive audit events. - * - Call [setRateLimitHook] to enforce rate limits. - * - Call [setLookupCache] to enable token caching (or add `ogiri-caffeine` / `ogiri-redis`). - * - * The auto-configuration wires these via `ObjectProvider.ifAvailable` after construction. Callers - * that extend this class directly simply call the setters on `this` before the service is first - * used. - */ -@OgiriService -open class OgiriTokenService -constructor( - private val repository: OgiriTokenRepository, - private val passwordEncoder: PasswordEncoder, - private val userDirectory: OgiriUserDirectory, - private val identifierPolicy: IdentifierPolicy, - private val subTokenRegistry: OgiriSubTokenRegistry, - protected val properties: OgiriConfigurationProperties, -) { - private var auditHook: OgiriAuditHook = NoOpOgiriAuditHook - private var rateLimitHook: OgiriRateLimitHook = NoOpOgiriRateLimitHook - private var lookupCache: OgiriTokenLookupCache? = null - - private val subTokenService = SubTokenService(subTokenRegistry, repository, userDirectory) - - init { - subTokenService.lookupToken = ::lookupToken - subTokenService.createOrUpdateToken = ::createOrUpdateToken - subTokenService.deleteToken = { userId, clients -> deleteToken(userId, clients) } - subTokenService.doesTokenMatch = ::doesTokenMatch - } - - /** Replaces the audit hook. Defaults to [NoOpOgiriAuditHook] when not called. */ - open fun setAuditHook(hook: OgiriAuditHook) { - this.auditHook = hook - subTokenService.setAuditHook(hook) - } - - /** Replaces the rate-limit hook. Defaults to [NoOpOgiriRateLimitHook] when not called. */ - open fun setRateLimitHook(hook: OgiriRateLimitHook) { - this.rateLimitHook = hook - } - - /** - * Wires the lookup cache. When not called the service falls through to the repository on every - * read — identical behaviour to having no cache configured. - */ - open fun setLookupCache(cache: OgiriTokenLookupCache) { - this.lookupCache = cache - } - - private val maxClients: Long = properties.auth.maxClients - private val batchGraceSeconds: Long = properties.auth.batchGraceSeconds - private val tokenLifespanDays: Long = properties.auth.tokenLifespanDays - - /** - * Cache for token comparison results to avoid repeated BCrypt operations. - * - * Uses configuration from [OgiriConfigurationProperties.CacheProperties] for size and expiry. - */ - private val tokenEqualityCache: Cache by lazy { - Caffeine.newBuilder() - .maximumSize(properties.cache.maxSize) - .expireAfterWrite(properties.cache.expiryMinutes, TimeUnit.MINUTES) - .build() - } - - /** - * Cache for batch request detection timestamps to avoid database queries. - * - * Stores the last update timestamp for each user:client combination. Entries expire slightly - * after the batch grace period to ensure stale entries don't interfere with detection. - */ - private val batchTimestampCache: Cache by lazy { - Caffeine.newBuilder() - .maximumSize(properties.cache.maxSize) - .expireAfterWrite(batchGraceSeconds + 1, TimeUnit.SECONDS) - .build() - } - - /** - * Look up a token by userId/client, checking [lookupCache] before the repository. - * - * On a cache miss the result is stored so subsequent reads within the same cache window skip the - * DB entirely. - */ - private fun lookupToken(userId: Long, client: String): T? { - lookupCache?.get(userId, client)?.let { - return it - } - val token = repository.findByUserIdAndClient(userId, client).orElse(null) - token?.let { lookupCache?.put(userId, client, it) } - return token - } - - private fun evictFromCache(userId: Long, client: String) { - lookupCache?.evict(userId, client) - } - - private fun evictAllFromCache(userId: Long) { - lookupCache?.evictAll(userId) - } - - private fun batchCacheKey( - userId: Long, - client: String, - ): String = "$userId:$client" - - private val cachedRegistrations: List - get() = subTokenService.cachedRegistrations - - /** - * Check if a plain token matches a hashed token, using cache to avoid repeated BCrypt operations. - * - * Cache keys are hashed to prevent plaintext token extraction from memory dumps. - * - * @param tokenHash The BCrypt-hashed token from the database - * @param token The plaintext token to validate - * @return true if tokens match, false otherwise - */ - private fun tokensMatch( - tokenHash: String, - token: String, - ): Boolean { - val key = "${tokenHash.length}:${sha256(tokenHash)}:${token.length}:${sha256(token)}" - return tokenEqualityCache.get(key) { passwordEncoder.matches(token, tokenHash) } - } - - /** - * Compute SHA-256 hash for cache key generation. - * - * @param input The string to hash - * @return Hex-encoded SHA-256 hash - */ - private fun sha256(input: String): String = - SHA256.get().let { md -> - md.reset() - md.digest(input.toByteArray()).joinToString("") { "%02x".format(it) } - } - - /** - * Factory function for creating new token instances. - * - * This method can be overridden by subclasses to provide custom token creation logic. Users can - * extend OgiriTokenService and override this method to instantiate their custom OgiriToken - * implementations. - * - * Example: - * ```kotlin - * class MyTokenService( - * repository: OgiriTokenRepository, - * // ... other dependencies - * ) : OgiriTokenService(repository, ...) { - * override fun tokenFactory( - * userId: Long, - * client: String, - * hashedToken: String, - * tokenType: OgiriTokenType, - * expiry: Instant, - * tokenSubtype: String?, - * plainTokenValue: String, - * ): T = MyToken( - * userId = userId, - * client = client, - * token = hashedToken, - * tokenType = tokenType, - * tokenSubtype = tokenSubtype, - * expiryAt = expiry, - * plainToken = plainTokenValue - * ) as T - * } - * ``` - * - * @param userId The user ID for the token - * @param client The client/application identifier - * @param hashedToken The hashed (encoded) token value - * @param tokenType The token type (APP or SUB) - * @param expiry The token expiration time - * @param tokenSubtype Optional sub-token type (e.g., "device", "chat") - * @param plainTokenValue The plain (unhashed) token value for temporary in-memory use - * @return A new token instance configured with the provided values - */ - @Suppress("UNCHECKED_CAST") - protected open fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): T { - throw UnsupportedOperationException( - "TokenService.tokenFactory() must be overridden by subclass. " + - "Extend TokenService and provide your token implementation. " + - "See class documentation for example implementation.", - ) - } - - /** - * Returns all tokens (APP and SUB) for [userId], ordered by most recently updated first. Expired - * tokens are included; filter on [OgiriToken.isExpired] if needed. - */ - @Transactional(readOnly = true) - fun getAllByUserId(userId: Long): List = repository.findByUserIdOrderByUpdatedAtDesc(userId) - - /** - * Returns the token for [userId]/[client], or `null` if none exists. Expired tokens are returned; - * check [OgiriToken.isExpired] if currency matters. - */ - @Transactional(readOnly = true) - fun getByUserIdAndClient( - userId: Long, - client: String, - ): T? = repository.findByUserIdAndClient(userId, client).orElse(null) - - /** - * Deletes the token for [userId]/[client] and evicts it from the lookup cache. Silent no-op if no - * matching token exists. Does not cascade to sub-tokens. - */ - @Transactional - fun deleteToken( - userId: Long, - client: String, - ) { - repository.deleteByUserIdAndClient(userId, client) - evictFromCache(userId, client) - } - - /** - * Deletes tokens for [userId] matching any of the given [clients] and evicts them from the lookup - * cache. Silent no-op for any client IDs that have no matching token. Does not cascade to - * sub-tokens. - */ - @Transactional - fun deleteToken( - userId: Long, - clients: Collection, - ) { - if (clients.isEmpty()) return - repository.deleteByUserIdAndClientIn(userId, clients) - clients.forEach { evictFromCache(userId, it) } - } - - /** - * Deletes all tokens (APP and SUB) for [userId] and evicts all user entries from the lookup - * cache. - */ - @Transactional - fun deleteAllForUser(userId: Long) { - repository.deleteByUserId(userId) - evictAllFromCache(userId) - } - - /** - * Deletes all expired tokens in a single bulk DELETE. - * - * For large token tables prefer [cleanupExpiredTokensBatched] to avoid long table locks. - * - * @param now Cutoff instant; tokens with `expiryAt` before this value are deleted. - * @return Number of tokens deleted. - */ - @Transactional - fun cleanupExpiredTokens(now: Instant = Instant.now()): Int = - repository.deleteByExpiryAtBefore(now) - - /** - * Clean up expired tokens in batches. - * - * This method repeatedly deletes batches of expired tokens until none remain, using the batch - * size from configuration. This is more efficient for large-scale cleanup as it avoids - * overwhelming the database with a single large DELETE operation. - * - * @param now The cutoff time for expiry comparison. Tokens with expiryAt before this are deleted. - * @return Total number of tokens deleted across all batches. - */ - @Transactional - fun cleanupExpiredTokensBatched(now: Instant = Instant.now()): Int { - val batchSize = properties.cleanup.batchSize - var totalDeleted = 0 - var deleted: Int - do { - val expired = repository.fetchTopExpiredBefore(now, batchSize) - val ids = expired.map { it.id } - if (ids.isNotEmpty()) repository.deleteByIdIn(ids) - deleted = expired.size - totalDeleted += deleted - } while (deleted == batchSize) - return totalDeleted - } - - /** - * Returns `true` when the [user]/[client] token was last updated within [batchGraceSeconds] of - * [requestStartedAt], indicating that a concurrent batch of requests is in flight. - * - * Batch requests should call [extendBatchBuffer] instead of triggering a full token rotation. - * Checks the in-memory timestamp cache before falling back to the database. - * - * @param requestStartedAt The instant at which the current request began (caller's clock). - */ - @Transactional(readOnly = true) - fun isBatchRequest( - user: OgiriUser, - client: String, - requestStartedAt: Instant, - ): Boolean { - val userId = user.getOgiriUserId() - val cacheKey = batchCacheKey(userId, client) - - val cachedTimestamp = batchTimestampCache.getIfPresent(cacheKey) - if (cachedTimestamp != null) { - val threshold = requestStartedAt.minusSeconds(batchGraceSeconds) - return cachedTimestamp.isAfter(threshold) || cachedTimestamp == threshold - } - - // Cache miss - query database (lookupToken populates entity cache on miss) - val token = lookupToken(userId, client) - val updatedAt = token?.takeIf { !it.isExpired() }?.updatedAt ?: return false - - batchTimestampCache.put(cacheKey, updatedAt) - val threshold = requestStartedAt.minusSeconds(batchGraceSeconds) - return updatedAt.isAfter(threshold) || updatedAt == threshold - } - - /** - * Refreshes the batch window for a user/client without rotating the token. - * - * Writes `lastUsedAt` to the database only when the stored value is older than half of - * [batchGraceSeconds], reducing write pressure during high-frequency batch requests. - * - * @param token Plain (unhashed) access-token value from the request headers. - * @return Refreshed [AuthHeader], or `null` if no token exists for this user/client. - */ - @Transactional - fun extendBatchBuffer( - user: OgiriUser, - token: String, - client: String, - ): AuthHeader? { - val userId = user.getOgiriUserId() - val clientToken = lookupToken(userId, client) ?: return null - val now = Instant.now() - - // Only update database if timestamp is stale (older than half batch window) - // This reduces write load while keeping the timestamp reasonably fresh - val lastUsed = clientToken.lastUsedAt - val updateThreshold = batchGraceSeconds / 2 - val shouldUpdate = lastUsed == null || lastUsed.plusSeconds(updateThreshold).isBefore(now) - - if (shouldUpdate) { - clientToken.lastUsedAt = now - repository.save(clientToken) - batchTimestampCache.put(batchCacheKey(userId, client), now) - } - return updateAuthHeader(user, token, client) - } - - private fun clientForSubToken( - reg: OgiriSubTokenRegistration, - parentClient: String, - ): String = subTokenService.clientForSubToken(reg, parentClient) - - @Transactional - @JvmOverloads - fun createOrUpdateToken( - user: OgiriUser, - client: String?, - expiry: Instant, - tokenType: OgiriTokenType = OgiriTokenType.APP, - tokenSubtype: String? = null, - ): T { - val tokenClient = client ?: identifierPolicy.generate() - val generatedToken = identifierPolicy.generate() - val hashedToken = passwordEncoder.encode(generatedToken) - var token = client?.let { lookupToken(user.getOgiriUserId(), it) } - val isRotation = token != null && token.id != 0L && tokenType == OgiriTokenType.APP - - if (token == null) { - token = - tokenFactory( - userId = user.getOgiriUserId(), - client = tokenClient, - hashedToken = hashedToken, - tokenType = tokenType, - tokenSubtype = tokenSubtype, - expiry = expiry, - plainTokenValue = generatedToken, - ) - } - - // Rotation bookkeeping: preserve previous token for batch grace period - if (isRotation) { - token.previousToken = token.lastToken - token.lastToken = token.token - } - - token.expiryAt = expiry - token.token = hashedToken - token.tokenUpdatedAt = Instant.now() - token.plainToken = generatedToken - - val savedToken = repository.save(token) - evictFromCache(user.getOgiriUserId(), savedToken.client) - batchTimestampCache.invalidate(batchCacheKey(user.getOgiriUserId(), savedToken.client)) - - if (isRotation) { - auditHook.onTokenRotated(user.getOgiriUserId(), savedToken.client) - } - - return savedToken - } - - /** - * Creates a new APP token for [user]/[client], issues all default sub-tokens, and conditionally - * cleans up old tokens when approaching the [maxClients] limit. - * - * Lower-level than [createNewAuthToken]. Prefer [createNewAuthToken] for the full login flow, - * which also handles rate limiting and produces a ready-to-send [AuthHeader]. - * - * @param client Optional client ID; a random ID is generated when `null`. - * @return The created APP token and a map of sub-tokens keyed by registration name. - */ - @Transactional - fun createToken( - user: OgiriUser, - client: String?, - ): OgiriGeneratedTokens { - val expiry = Instant.now().plus(tokenLifespanDays, ChronoUnit.DAYS) - val appToken = createOrUpdateToken(user, client, expiry) - val subTokens = issueSubTokens(user, appToken.client, null, forceNew = false) - maybeCleanOldTokens(user) - return OgiriGeneratedTokens(appToken, subTokens) - } - - /** - * Issue sub-tokens for a parent client. - * - * @param requestedNames optional whitelist of sub-token names; when null, defaults are issued. - * @param forceNew when true, existing sub-tokens are rotated even if the registration is - * forceNew=false. - */ - @Transactional - fun issueSubTokens( - user: OgiriUser, - parentClient: String, - requestedNames: Collection?, - forceNew: Boolean, - ): Map = - subTokenService.issueSubTokens( - user, parentClient, requestedNames, forceNew, tokenLifespanDays) - - /** - * Create a new authentication token for a user and client. - * - * This is the primary public API for token issuance. It performs the following: - * 1. Loads the user from OgiriUserDirectory, throws SecurityServiceException if not found - * 2. Generates a new APP token with expiry based on [tokenLifespanDays] - * 3. Issues all sub-tokens marked with includeByDefault=true - * 4. Enforces [maxClients] limit by removing oldest tokens if exceeded - * 5. Returns AuthHeader with APP token and all sub-tokens serialized for HTTP response - * - * The returned AuthHeader should be appended to the HTTP response via [appendAuthHeaders], which - * will set response headers: access-token, client, uid, expiry, sub-tokens - * - * @param userId User identifier to create token for - * @param client Optional client identifier. If null, a random identifier is generated. - * @return AuthHeader containing access-token, client, uid, expiry, and sub-tokens - * @throws SecurityServiceException if user not found ("user.not_found") or other auth failures - * - * Example: - * ```kotlin - * val authHeader = tokenService.createNewAuthToken(userId = 123L, client = "web-app") - * response.appendAuthHeaders(authHeader) // Sets response headers - * ``` - */ - @Transactional - fun createNewAuthToken( - userId: Long, - client: String?, - request: HttpServletRequest? = - (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request, - ): AuthHeader { - request?.let { rateLimitHook.beforeTokenCreation(it, userId) } - val user = userDirectory.findById(userId) ?: throw SecurityServiceException("user.not_found") - val generatedTokens = createToken(user, client) - return updateAuthHeader( - user = user, - token = generatedTokens.appToken.plainToken!!, - client = generatedTokens.appToken.client, - issuedSubTokens = generatedTokens.subTokens, - ) - } - - /** - * Enforce token limits and clean up orphaned tokens for a user. - * - * This method performs two critical cleanup operations: - * 1. **Orphan Cleanup**: Removes sub-tokens whose parent APP tokens no longer exist (e.g., if an - * APP token was manually deleted but its chat/device sub-tokens remain) - * 2. **Max Clients Enforcement**: Removes oldest APP tokens when [maxClients] limit is exceeded - * - Tracks usage via lastUsedAt and updatedAt to identify least-recently-used tokens - * - Cascade-deletes corresponding sub-tokens when APP token is removed - * - * Called automatically after token creation to enforce limits. Can be called manually to reclaim - * storage from unused tokens. - * - * @param user User whose tokens should be cleaned - * - * Token selection for removal (when exceeding maxClients): - * - Sorts by lastUsedAt (with fallback to updatedAt if lastUsedAt is null) - * - Removes oldest tokens first (least recently used) - * - Deletes all associated sub-tokens when parent APP token is removed - * - * Example: If maxClients=5 and user has 7 APP tokens, the 2 oldest are deleted along with all - * their device/chat/api sub-tokens. - */ - @Transactional - fun cleanOldTokens(user: OgiriUser) { - val tokens = repository.findByUserIdOrderByUpdatedAtDesc(user.getOgiriUserId()) - if (tokens.isEmpty()) return - - val (appTokens, subTokens) = - tokens.partition { OgiriTokenType.of(it.tokenType) == OgiriTokenType.APP } - - cleanOrphanedSubTokens(user.getOgiriUserId(), subTokens, appTokens) - enforceMaxClientsLimit(user, appTokens) - } - - private fun cleanOrphanedSubTokens(userId: Long, subTokens: List, appTokens: List) { - val expectedSubClients = expectedSubClientsFor(appTokens.clientIds(), cachedRegistrations) - val orphans = subTokens.filterOutClientIds(expectedSubClients) - if (orphans.isNotEmpty()) { - deleteToken(userId, orphans.map { it.client }) - } - } - - private fun enforceMaxClientsLimit(user: OgiriUser, appTokens: List) { - if (appTokens.size <= maxClients) return - - val toRemoveCount = (appTokens.size - maxClients).toInt() - val sorted = - appTokens.sortedWith( - compareBy { it.lastUsedAt ?: it.updatedAt }.thenBy { it.updatedAt }, - ) - val remove = sorted.take(toRemoveCount) - deleteToken(user.getOgiriUserId(), remove.map { it.client }) - val removeSubClients = expectedSubClientsFor(remove.clientIds(), cachedRegistrations) - deleteToken(user.getOgiriUserId(), removeSubClients) - } - - /** - * Conditionally clean old tokens only when approaching the max clients limit. - * - * This optimization reduces database operations by skipping cleanup when the user has - * significantly fewer tokens than the maximum allowed. Cleanup is only triggered when the token - * count reaches 80% of [maxClients]. - * - * @param user User whose tokens should potentially be cleaned - */ - @Transactional - fun maybeCleanOldTokens(user: OgiriUser) { - val tokenCount = repository.countByUserId(user.getOgiriUserId()) - val threshold = (maxClients * CLEANUP_THRESHOLD_RATIO).toLong() - if (tokenCount >= threshold) { - cleanOldTokens(user) - } - } - - /** - * Validate if a plain token matches the stored token for a user/client combination. - * - * This method checks token validity in the following order: - * 1. Verify token exists in database for this user/client - * 2. Check if token matches the current token (account for recent rotation) - * 3. Allow grace-period reuse of the previous token within [batchGraceSeconds] - * - * The grace period allows legitimate batch requests (multiple simultaneous API calls) to succeed - * even if they arrive after token rotation has started. - * - * @param plainToken The plain token value from HTTP headers to validate - * @param user Authenticated user object - * @param client Client identifier associated with the token - * @return true if token is valid and matches stored token, false otherwise - * - * Used by OgiriTokenAuthenticationFilter during authentication. Does not throw exceptions; - * authentication failures are delegated to AuthenticationEntryPoint. - */ - @Transactional(readOnly = true) - fun validToken( - plainToken: String, - user: OgiriUser, - client: String, - ): Boolean { - val token = lookupToken(user.getOgiriUserId(), client) ?: return false - if (tokenIsCurrent(plainToken, token)) return true - return tokenCanBeReused(plainToken, token) - } - - private fun tokenIsCurrent( - plainToken: String, - token: T, - ): Boolean { - val tokenHash = token.token - val previousTokenHash = token.previousToken - if (token.isExpired()) return false - return doesTokenMatch(tokenHash, plainToken) || doesTokenMatch(previousTokenHash, plainToken) - } - - private fun doesTokenMatch( - hashedToken: String?, - plainToken: String?, - ): Boolean { - if (hashedToken.isNullOrBlank() || plainToken.isNullOrBlank()) return false - return tokensMatch(hashedToken, plainToken) - } - - private fun tokenCanBeReused( - plainToken: String, - token: T, - ): Boolean { - val lastTokenHash = token.lastToken ?: return false - val gracePeriodThreshold = Instant.now().minusSeconds(batchGraceSeconds) - if (token.updatedAt.isBefore(gracePeriodThreshold)) return false - return doesTokenMatch(lastTokenHash, plainToken) - } - - /** - * Builds an [AuthHeader] for [user]/[client] without triggering token cleanup or rotation. - * - * Batch-fetches sub-token entries to avoid N+1 queries. When [issuedSubTokens] is provided, those - * plain-token values take precedence over persisted values in the response. - * - * @param token Plain (unhashed) access-token value to embed in the header. - * @param issuedSubTokens Newly issued sub-tokens whose plain values should be returned. Pass - * `null` to use only persisted sub-tokens. - * @see updateAuthHeader for the variant that also runs [maybeCleanOldTokens]. - */ - @Transactional(readOnly = true) - fun buildAuthHeader( - user: OgiriUser, - token: String, - client: String, - issuedSubTokens: Map? = null, - ): AuthHeader { - val appToken = lookupToken(user.getOgiriUserId(), client) - - // Batch fetch all sub-tokens to avoid N+1 queries - val subClients = cachedRegistrations.map { it.clientIdFor(client) } - val storedTokens = - repository.findByUserIdAndClientIn(user.getOgiriUserId(), subClients).associateBy { - it.client - } - - val subHeaders = mutableMapOf() - cachedRegistrations.forEach { reg -> - val subClient = reg.clientIdFor(client) - val provided = issuedSubTokens?.get(reg.name) - val stored = storedTokens[subClient] - val chosen = provided ?: stored - val plain = provided?.plainToken ?: chosen?.plainToken - val expiry = chosen?.expiryAt?.toString() - subHeaders[reg.name] = - SubTokenHeader( - client = subClient, - token = plain, - expiry = expiry, - ) - } - val filteredSubHeaders = - subHeaders.filterValues { !it.client.isNullOrBlank() || !it.token.isNullOrBlank() } - return AuthHeader( - accessToken = token, - client = client, - uid = user.username, - expiry = appToken?.expiryAt?.toString(), - kind = OgiriTokenType.APP.label, - subTokens = filteredSubHeaders.ifEmpty { null }, - ) - } - - /** - * Builds a refreshed [AuthHeader] and conditionally runs [maybeCleanOldTokens]. - * - * Equivalent to [buildAuthHeader] plus a cleanup pass. Use [buildAuthHeader] directly when the - * caller manages cleanup separately. - * - * @param token Plain (unhashed) access-token value. - */ - @Transactional - fun updateAuthHeader( - user: OgiriUser, - token: String, - client: String, - issuedSubTokens: Map? = null, - ): AuthHeader { - val authHeaders = buildAuthHeader(user, token, client, issuedSubTokens) - maybeCleanOldTokens(user) - return authHeaders - } - - /** - * Returns `true` if the token for [user]/[client] should be rotated. - * - * Rotation is triggered when the token is missing or when [OgiriToken.tokenUpdatedAt] is older - * than [thresholdSeconds] ago. Passing `thresholdSeconds <= 0` always returns `true` - * (unconditional rotation). - */ - @Transactional(readOnly = true) - fun shouldRotate( - user: OgiriUser, - client: String, - thresholdSeconds: Long, - ): Boolean { - if (thresholdSeconds <= 0) return true - val token = lookupToken(user.getOgiriUserId(), client) ?: return true - val cutoff = Instant.now().minusSeconds(thresholdSeconds) - return token.tokenUpdatedAt.isBefore(cutoff) - } - - /** - * Authenticates a user by email/password and issues a new APP token. - * - * On success: - * 1. Validates the password via BCrypt (constant-time comparison to prevent timing enumeration). - * 2. Sets the Spring [org.springframework.security.core.context.SecurityContext]. - * 3. Creates a new APP token and appends auth headers to [response]. - * 4. Calls [OgiriUserDirectory.recordSuccessfulLogin] and [OgiriAuditHook.onLoginSuccess]. - * - * @throws [com.quantipixels.ogiri.security.core.SecurityServiceException] with code - * `"error.auth.invalid_credentials"` for unknown users or wrong passwords. - */ - @Transactional - fun verifyUser( - request: HttpServletRequest, - response: HttpServletResponse, - identifier: String, - password: String, - ) { - rateLimitHook.beforeLogin(request, identifier) - - val user = userDirectory.findByEmail(identifier) - val clientIp = request.remoteAddr - - if (user == null) { - // Constant-time dummy comparison to prevent timing enumeration - passwordEncoder.matches(password, DUMMY_HASH) - auditHook.onLoginFailure(identifier, "user_not_found", clientIp) - throw SecurityServiceException("error.auth.invalid_credentials") - } - if (!passwordEncoder.matches(password, user.password)) { - auditHook.onLoginFailure(identifier, "invalid_password", clientIp) - throw SecurityServiceException("error.auth.invalid_credentials") - } - - val authentication = - UsernamePasswordAuthenticationToken(user, "[PROTECTED_PASSWORD]", user.authorities) - authentication.details = WebAuthenticationDetailsSource().buildDetails(request) - SecurityContextHolder.getContext().authentication = authentication - - val authHeaders = createNewAuthToken(user.getOgiriUserId(), null, request) - response.appendAuthHeaders(authHeaders, properties.cookies) - userDirectory.recordSuccessfulLogin(user.getOgiriUserId()) - auditHook.onLoginSuccess(user.getOgiriUserId(), authHeaders.client!!, clientIp) - } - - companion object { - private val logger = LoggerFactory.getLogger(OgiriTokenService::class.java) - - // Valid BCrypt hash for timing normalization — prevents user enumeration via response-time - // diff. - // Generated fresh each startup; a new hash each run means no pre-computed attack applies. - private val DUMMY_HASH = BCryptPasswordEncoder().encode("dummy") - - private val SHA256: ThreadLocal = - ThreadLocal.withInitial { java.security.MessageDigest.getInstance("SHA-256") } - } - - /** - * Revokes the token for the client identified in the request's auth headers. - * - * Validates the access token before deleting. Silent no-op when the user is not found, the client - * header is absent, or the token does not match. On successful revocation cascades to all - * associated sub-tokens and appends (now-invalid) auth headers to [response]. - */ - @Transactional - fun revokeClient( - userId: Long, - request: HttpServletRequest, - response: HttpServletResponse, - ) { - val user = userDirectory.findById(userId) ?: return - val authHeader = request.extractAuthHeader() - val clientId = authHeader.client ?: return - val token = lookupToken(user.getOgiriUserId(), clientId) - val isTokenValid = token?.let { doesTokenMatch(it.token, authHeader.accessToken) } ?: false - - if (isTokenValid) { - deleteToken(user.getOgiriUserId(), clientId) - val subClients = - cachedRegistrations.map { registration -> registration.clientIdFor(clientId) } - deleteToken(user.getOgiriUserId(), subClients) - val authHeaders = buildAuthHeader(user, authHeader.accessToken!!, clientId, null) - response.appendAuthHeaders(authHeaders, properties.cookies) - auditHook.onTokenRevoked(user.getOgiriUserId(), clientId) - } - } - - /** Force rotation of a specific sub-token and return fresh headers. */ - @Transactional - fun renewSubToken( - userId: Long, - request: HttpServletRequest, - response: HttpServletResponse, - name: String, - ) { - rateLimitHook.beforeSubTokenRenewal(request, userId) - val currentUser = - userDirectory.findById(userId) ?: throw SecurityServiceException("user.not_found") - val authHeader = request.extractAuthHeader() - - val clientId = authHeader.client ?: throw SecurityServiceException("error.auth.missing_token") - val parent = - lookupToken(currentUser.getOgiriUserId(), clientId) - ?: throw SecurityServiceException("error.auth.missing_token") - if (parent.isExpired()) throw SecurityServiceException("error.auth.bad_credentials") - - val issued = - issueSubTokens( - user = currentUser, - parentClient = clientId, - requestedNames = setOf(name), - forceNew = true, - ) - val authHeaders = - buildAuthHeader( - currentUser, - authHeader.accessToken!!, - clientId, - issuedSubTokens = issued, - ) - response.appendAuthHeaders(authHeaders, properties.cookies) - } - - /** - * Attempts to decode a Base64-encoded JSON sub-bearer fragment into a [SubTokenHeader]. - * - * [encodedPart] must be the raw Base64 segment — without a `Bearer ` prefix. Returns `null` on - * Base64 decoding failure, JSON parse error, or type cast error. All other exceptions are - * re-thrown. - */ - fun tryDecodeSubBearer(encodedPart: String): SubTokenHeader? = - subTokenService.tryDecodeSubBearer(encodedPart) - - /** - * Validate a sub-token for a user. - * - * Accepts either a raw token or a base64-encoded bearer JSON containing `client`, `token`, - * optional `expiry`. Expiry is always verified server-side. - */ - @Transactional - fun validateSubToken( - username: String, - subTokenName: String, - rawOrBearer: String, - ): Boolean = subTokenService.validateSubToken(username, subTokenName, rawOrBearer) - - /** - * Retrieve a sub-token for a user by name. - * - * Returns the raw [OgiriToken] so consumers that only know the generic contract can inspect the - * stored hash, expiry, or client identifiers without depending on a concrete token class. - * - * @param userId User ID - * @param subtype Sub-token identifier (e.g., "device", "chat", "notification") - * @return The sub-token entity, or null if not found, expired, or not registered - */ - @Transactional(readOnly = true) - fun getSubToken( - userId: Long, - subtype: String, - ): OgiriToken? = subTokenService.getSubToken(userId, subtype) - - /** - * Revoke all sub-tokens of a specific type for a user. - * - * This removes all tokens where tokenSubtype matches the given name, regardless of parent client. - * Useful for revoking device sessions, chat credentials, etc. - * - * @param userId User ID - * @param subtypeName Sub-token type to revoke (e.g., "device", "chat") - * @return true if at least one token was revoked, false if none existed - */ - @Transactional - fun revokeSubToken( - userId: Long, - subtypeName: String, - ): Boolean = subTokenService.revokeSubToken(userId, subtypeName) - - /** - * Renew a specific sub-token and return new headers containing only that sub-token. - * - * The returned [AuthHeader] omits the APP token so consumers can append the new sub-token to - * downstream protocols without rotating the parent client. Returns null when the user/client is - * missing, the parent token is expired, or the subtype is not registered. - */ - @Transactional - fun renewSubToken( - userId: Long, - parentClient: String, - subtypeName: String, - ): AuthHeader? = subTokenService.renewSubToken(userId, parentClient, subtypeName) - - private fun expectedSubClientsFor( - appClients: Set, - registrations: List, - ): Set = subTokenService.expectedSubClientsFor(appClients, registrations) -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceResolver.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceResolver.kt deleted file mode 100644 index 0c8d8a6..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceResolver.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -interface OgiriTokenServiceResolver { - /** - * Resolve and provide the active OgiriTokenService implementation. - * - * @return an instance of [OgiriTokenService] with an unspecified generic type representing the - * resolved token service implementation. - */ - fun resolve(): OgiriTokenService<*> -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenType.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenType.kt deleted file mode 100644 index bc82381..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenType.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -/** - * Token type classifier enumeration. - * - * Used to distinguish between primary application tokens ("app") and sub-tokens ("sub") for - * specialized use cases (e.g., device tokens, chat tokens). - * - * Implementations can persist this as: - * - A string column (storing the label: "app" or "sub") - * - An enum column (JPA @Enumerated) - * - A numeric column with custom conversion logic - * - * Example - JPA Entity with string mapping: - * ```kotlin - * @Entity - * class MyToken : OgiriBaseToken() { - * @Column(name = "token_type", nullable = false) - * val tokenType: String = "app" // or use @Enumerated(EnumType.STRING) - * } - * ``` - * - * Example - JDBC Token with manual conversion: - * ```kotlin - * data class JdbcToken( - * override val tokenType: String, // stored as "app" or "sub" - * ) : OgiriBaseToken() - * ``` - */ -enum class OgiriTokenType(val label: String) { - /** Primary application token for user authentication and authorization. */ - APP("app"), - - /** Sub-token for specialized use cases (device, chat, API, etc.). */ - SUB("sub"), - ; - - companion object { - /** - * Parse an OgiriTokenType from the given label using a case-insensitive match. - * - * @param label The token type label, expected to be "app" or "sub". - * @return The `OgiriTokenType` that corresponds to the provided label. - * @throws IllegalArgumentException if the label does not match any token type. - */ - fun of(label: String): OgiriTokenType = - entries.firstOrNull { it.label.equals(label, ignoreCase = true) } - ?: throw IllegalArgumentException("Invalid token type: $label") - - /** - * Parse an OgiriTokenType from a label, defaulting to APP when the label is null. - * - * @param label The token type label ("app" or "sub"), or null to use the APP default. - * @return The matching OgiriTokenType, or APP if label is null. - * @throws IllegalArgumentException if label is non-null and does not match any known type. - */ - fun ofOrDefault(label: String?): OgiriTokenType { - // Use strict semantics: null defaults to APP, but invalid non-null labels are errors. - if (label == null) return APP - return entries.firstOrNull { it.label.equals(label, ignoreCase = true) } - ?: throw IllegalArgumentException("Invalid token type label: $label") - } - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/SubTokenService.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/SubTokenService.kt deleted file mode 100644 index 465e1b5..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/tokens/SubTokenService.kt +++ /dev/null @@ -1,286 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.fasterxml.jackson.core.JsonProcessingException -import com.quantipixels.ogiri.security.core.AuthHeader -import com.quantipixels.ogiri.security.core.JsonCodec -import com.quantipixels.ogiri.security.core.SubTokenHeader -import com.quantipixels.ogiri.security.spi.NoOpOgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriUser -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.Base64 -import org.slf4j.LoggerFactory -import org.springframework.transaction.annotation.Transactional - -/** - * Handles sub-bearer token creation, validation, and registry-driven lifecycle operations. - * - * This class is internal to the tokens package. It is instantiated and owned by - * [OgiriTokenService], which delegates all sub-token concerns here. - * - * The functional callbacks — [lookupToken], [createOrUpdateToken], [deleteToken], [doesTokenMatch] - * — are injected by [OgiriTokenService] after construction to avoid a circular constructor - * dependency. - */ -internal class SubTokenService( - private val subTokenRegistry: OgiriSubTokenRegistry, - private val repository: OgiriTokenRepository, - private val userDirectory: OgiriUserDirectory, -) { - private var auditHook: OgiriAuditHook = NoOpOgiriAuditHook - - internal fun setAuditHook(hook: OgiriAuditHook) { - this.auditHook = hook - } - - // Callbacks wired by OgiriTokenService after construction - internal lateinit var lookupToken: (userId: Long, client: String) -> T? - internal lateinit var createOrUpdateToken: - ( - user: OgiriUser, - client: String?, - expiry: Instant, - tokenType: OgiriTokenType, - tokenSubtype: String?) -> T - internal lateinit var deleteToken: (userId: Long, clients: Collection) -> Unit - internal lateinit var doesTokenMatch: (hashedToken: String?, plainToken: String?) -> Boolean - - internal val cachedRegistrations: List by lazy { - subTokenRegistry.registrations() - } - - internal fun clientForSubToken( - reg: OgiriSubTokenRegistration, - parentClient: String, - ): String = reg.clientIdFor(parentClient) - - @Transactional - internal fun issueSubTokens( - user: OgiriUser, - parentClient: String, - requestedNames: Collection?, - forceNew: Boolean, - tokenLifespanDays: Long, - ): Map { - val registrations = - cachedRegistrations.filter { - requestedNames?.let { names -> it.name in names } ?: it.includeByDefault - } - val results = mutableMapOf() - val parentToken = lookupToken(user.getOgiriUserId(), parentClient) - val parentExpiry = - parentToken?.expiryAt ?: Instant.now().plus(tokenLifespanDays, ChronoUnit.DAYS) - registrations.forEach { reg -> - val subClient = clientForSubToken(reg, parentClient) - val expiry = reg.expiry(parentExpiry) - val existing = - if (!forceNew && !reg.forceNew) lookupToken(user.getOgiriUserId(), subClient) else null - val token = - existing - ?: createOrUpdateToken( - user, - subClient, - expiry, - OgiriTokenType.SUB, - reg.name, - ) - results[reg.name] = token - - if (existing == null) { - auditHook.onSubTokenCreated(user.getOgiriUserId(), parentClient, reg.name) - } - } - return results - } - - /** - * Attempts to decode a Base64-encoded JSON sub-bearer fragment into a [SubTokenHeader]. - * - * [encodedPart] must be the raw Base64 segment — without a `Bearer ` prefix. Returns `null` on - * Base64 decoding failure, JSON parse error, or type cast error. All other exceptions are - * re-thrown. - */ - internal fun tryDecodeSubBearer(encodedPart: String): SubTokenHeader? = - try { - val json = String(Base64.getDecoder().decode(encodedPart), Charsets.UTF_8) - JsonCodec.mapper.readValue(json, Map::class.java)?.let { map -> - @Suppress("UNCHECKED_CAST") val values = map as Map - SubTokenHeader( - client = values["client"] as? String, - token = values["token"] as? String, - expiry = values["expiry"] as? String, - ) - } - } catch (e: Exception) { - when (e) { - is IllegalArgumentException, - is JsonProcessingException, - is ClassCastException -> { - logger.trace( - "Sub-bearer decode failed (input length={}): {}", encodedPart.length, e.message) - null - } - else -> throw e - } - } - - private fun tokenMatches( - token: T, - plain: String, - ): Boolean = !token.expiryAt.isBefore(Instant.now()) && doesTokenMatch(token.token, plain) - - /** - * Validate a sub-token for a user. - * - * Accepts either a raw token or a base64-encoded bearer JSON containing `client`, `token`, - * optional `expiry`. Expiry is always verified server-side. - */ - @Transactional - internal fun validateSubToken( - username: String, - subTokenName: String, - rawOrBearer: String, - ): Boolean { - val user = userDirectory.findByUsername(username) ?: return false - val registration = cachedRegistrations.find { it.name == subTokenName } ?: return false - val tokenField = rawOrBearer.trim() - val authHeader = tryDecodeSubBearer(tokenField.removePrefix("Bearer ").trim()) - - if (authHeader != null && - !authHeader.token.isNullOrBlank() && - !authHeader.client.isNullOrBlank()) { - val token = - repository - .findByUserIdAndClient(user.getOgiriUserId(), authHeader.client) - .orElse(null) - ?.takeIf { - OgiriTokenType.of(it.tokenType) == OgiriTokenType.SUB && - it.tokenSubtype == subTokenName - } - ?: return false - return tokenMatches(token, authHeader.token) && registration.validate(authHeader.token) - } - - val all = - repository - .findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(user.getOgiriUserId(), subTokenName) - .filter { OgiriTokenType.of(it.tokenType) == OgiriTokenType.SUB } - return all.any { tokenMatches(it, tokenField) && registration.validate(tokenField) } - } - - /** - * Retrieve a sub-token for a user by name. - * - * Returns the raw [OgiriToken] so consumers that only know the generic contract can inspect the - * stored hash, expiry, or client identifiers without depending on a concrete token class. - * - * @param userId User ID - * @param subtype Sub-token identifier (e.g., "device", "chat", "notification") - * @return The sub-token entity, or null if not found, expired, or not registered - */ - @Transactional(readOnly = true) - internal fun getSubToken( - userId: Long, - subtype: String, - ): OgiriToken? { - if (cachedRegistrations.none { it.name == subtype }) return null - - return repository - .findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(userId, subtype) - .firstOrNull { token -> OgiriTokenType.of(token.tokenType) == OgiriTokenType.SUB } - } - - /** - * Revoke all sub-tokens of a specific type for a user. - * - * @param userId User ID - * @param subtypeName Sub-token type to revoke (e.g., "device", "chat") - * @return true if at least one token was revoked, false if none existed - */ - @Transactional - internal fun revokeSubToken( - userId: Long, - subtypeName: String, - ): Boolean { - val tokens = - repository.findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(userId, subtypeName).filter { - OgiriTokenType.of(it.tokenType) == OgiriTokenType.SUB - } - - if (tokens.isEmpty()) return false - - deleteToken(userId, tokens.map { it.client }) - auditHook.onSubTokenRevoked(userId, subtypeName) - return true - } - - /** - * Renew a specific sub-token and return new headers containing only that sub-token. - * - * The returned [AuthHeader] omits the APP token so consumers can append the new sub-token to - * downstream protocols without rotating the parent client. Returns null when the user/client is - * missing, the parent token is expired, or the subtype is not registered. - */ - @Transactional - internal fun renewSubToken( - userId: Long, - parentClient: String, - subtypeName: String, - ): AuthHeader? { - val user = userDirectory.findById(userId) ?: return null - val parentToken = lookupToken(userId, parentClient) ?: return null - if (parentToken.isExpired()) return null - - val registration = cachedRegistrations.find { it.name == subtypeName } ?: return null - val subClient = registration.clientIdFor(parentClient) - - val newSubToken = - createOrUpdateToken( - user, - subClient, - registration.expiry(parentToken.expiryAt), - OgiriTokenType.SUB, - subtypeName, - ) - - val plain = newSubToken.plainToken ?: return null - val subHeader = - SubTokenHeader( - client = subClient, - token = plain, - expiry = newSubToken.expiryAt.toString(), - ) - return AuthHeader(subTokens = mapOf(subtypeName to subHeader)) - } - - /** - * Generate expected sub-client IDs from app client IDs and registrations. - * - * @param appClients Set of app client IDs - * @param registrations Sub-token registrations - * @return Set of expected sub-client IDs - */ - internal fun expectedSubClientsFor( - appClients: Set, - registrations: List, - ): Set = - appClients.flatMap { parent -> registrations.map { it.clientIdFor(parent) } }.toSet() - - companion object { - private val logger = LoggerFactory.getLogger(SubTokenService::class.java) - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPoint.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPoint.kt deleted file mode 100644 index a7190f9..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPoint.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.web - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.ACCESS_TOKEN -import com.quantipixels.ogiri.security.core.CLIENT -import com.quantipixels.ogiri.security.core.EXPIRY -import com.quantipixels.ogiri.security.core.JsonCodec -import com.quantipixels.ogiri.security.core.UID -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.slf4j.LoggerFactory -import org.springframework.context.MessageSource -import org.springframework.context.i18n.LocaleContextHolder -import org.springframework.http.MediaType -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.AuthenticationEntryPoint - -class OgiriAuthenticationEntryPoint( - private val messageSource: MessageSource, - private val properties: OgiriConfigurationProperties, -) : AuthenticationEntryPoint { - private val logger = LoggerFactory.getLogger(OgiriAuthenticationEntryPoint::class.java) - private val mapper = JsonCodec.mapper - - companion object { - private val DEFAULT_MESSAGES = - mapOf( - "error.auth.bad_credentials" to "Invalid credentials", - "error.auth.required" to "Authentication required", - ) - } - - /** - * Clears authentication cookies from the response. - * - * Sets all authentication-related cookies (access-token, client, uid, expiry) to empty values - * with maxAge=0 to instruct the browser to delete them. This prevents clients from being stuck in - * a 401 loop with stale credentials when using HttpOnly cookies. - * - * The cookies are cleared using the same path configured in the cookie properties to ensure - * proper deletion scope. - * - * @param response The HTTP response where cookies will be cleared. - */ - private fun clearAuthCookies(response: HttpServletResponse) { - val cookieConfig = properties.cookies - listOf(ACCESS_TOKEN, CLIENT, UID, EXPIRY).forEach { name -> - val cookie = - Cookie(name, "").apply { - maxAge = 0 - path = cookieConfig.path - } - response.addCookie(cookie) - } - logger.debug("Cleared authentication cookies on 401 response") - } - - /** - * Sends a 401 JSON response containing a localized authentication error message. - * - * The response uses a message key chosen based on the exception type: - * - "error.auth.bad_credentials" when the exception is BadCredentialsException - * - "error.auth.required" for other authentication failures - * - * If no message is found in the MessageSource, a default message is used. - * - * When cookies are enabled (ogiri.cookies.enabled=true), this method also clears authentication - * cookies to prevent clients from being stuck in a 401 loop with stale credentials. This aligns - * with OWASP session management best practices for handling authentication failures. - * - * @param request The incoming HTTP request that triggered authentication. - * @param response The HTTP response that will be populated with status 401 and a JSON payload. - * @param authException The authentication exception used to determine the localized error - * message. - */ - override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: AuthenticationException, - ) { - logger.debug("Unauthorized request: {}", authException.message) - - // Clear authentication cookies if cookies are enabled to prevent 401 loops - if (properties.cookies.enabled) { - clearAuthCookies(response) - } - - response.contentType = MediaType.APPLICATION_JSON_VALUE - response.status = HttpServletResponse.SC_UNAUTHORIZED - - val locale = LocaleContextHolder.getLocale() - val code = - when (authException) { - is BadCredentialsException -> "error.auth.bad_credentials" - else -> "error.auth.required" - } - val message = - messageSource.getMessage( - code, null, DEFAULT_MESSAGES[code] ?: "Authentication error", locale) - val payload = mapOf("status" to HttpServletResponse.SC_UNAUTHORIZED, "message" to message) - mapper.writeValue(response.outputStream, payload) - } -} diff --git a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilter.kt b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilter.kt deleted file mode 100644 index bedb2ae..0000000 --- a/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilter.kt +++ /dev/null @@ -1,280 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.web - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.AuthHeader -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.core.appendAuthHeaders -import com.quantipixels.ogiri.security.core.extractAuthHeader -import com.quantipixels.ogiri.security.core.parseBearerToken -import com.quantipixels.ogiri.security.helpers.AuthenticationBypassDecider -import com.quantipixels.ogiri.security.spi.OgiriUser -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import com.quantipixels.ogiri.security.tokens.OgiriTokenType -import jakarta.servlet.FilterChain -import jakarta.servlet.ServletException -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import java.io.IOException -import java.time.Instant -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.AuthenticationException -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.AuthenticationEntryPoint -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource -import org.springframework.web.filter.OncePerRequestFilter - -/** - * Once-per-request token authentication filter. - * - Skips auth for whitelisted/public/preflight requests via [AuthenticationBypassDecider] - * - Validates APP tokens and rotates on configurable policies from [OgiriConfigurationProperties] - * - Emits refreshed headers (including sub-tokens) when rotation occurs - * - Populates the SecurityContext with the authenticated [OgiriUser] - * - * Configuration properties (from application.yml): - * - `ogiri.auth.rotate-on-write-only`: Only rotate on mutating requests (POST/PUT/DELETE) - * - `ogiri.auth.rotate-stale-seconds`: Force rotation if token exceeds this age - */ -open class OgiriTokenAuthenticationFilter( - private val userDirectory: OgiriUserDirectory, - private val tokenService: OgiriTokenService<*>, - private val authenticationEntryPoint: AuthenticationEntryPoint, - private val bypassDecider: AuthenticationBypassDecider, - private val identifierPolicy: IdentifierPolicy, - private val properties: OgiriConfigurationProperties, -) : OncePerRequestFilter() { - private val rotateOnWriteOnly: Boolean = properties.auth.rotateOnWriteOnly - private val rotateStaleSeconds: Long = properties.auth.rotateStaleSeconds - - /** - * Authenticate the incoming HTTP request, populate the SecurityContext, and append refreshed auth - * headers when tokens rotate. - * - * Performs authentication unless the request is exempted by the bypass decider. When - * authentication succeeds the filter sets the SecurityContext with the authenticated OgiriUser - * and, if a token rotation produced a new AuthHeader, appends refreshed authentication headers to - * the response. On authentication failure the security context is cleared and the configured - * AuthenticationEntryPoint is invoked. - * - * @param request HTTP request containing authentication headers or bearer token. - * @param response HTTP response where rotated authentication headers will be added when - * applicable. - * @param filterChain Filter chain to continue request processing after authentication handling. - * @throws ServletException if filter processing fails. - * @throws IOException if reading from or writing to the request/response fails. - */ - @Throws(ServletException::class, IOException::class) - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - if (bypassDecider.canSkip(request)) { - filterChain.doFilter(request, response) - return - } - - try { - val authResult = authenticateRequest(request) - afterAuth(request, response, authResult) - if (authResult != null) { - authResult.authHeader?.let { response.appendAuthHeaders(it, properties.cookies) } - SecurityContextHolder.getContext().authentication = - buildAuthentication(authResult.user, request) - } - } catch (e: AuthenticationException) { - SecurityContextHolder.clearContext() - authenticationEntryPoint.commence(request, response, e) - return - } - - filterChain.doFilter(request, response) - } - - /** - * Invoked before authentication is attempted for the incoming request. - * - * Default implementation does nothing; override to perform any preparatory work (for example - * request inspection, logging, or tracing) before authentication begins. - * - * @param request the HTTP request that will be authenticated - */ - protected open fun beforeAuth(request: HttpServletRequest) {} - - /** - * Extension point called after an authentication attempt to allow subclasses to react to the - * result. - * - * Implementations may inspect or modify the request and response, perform logging, metrics, or - * other side effects. - * - * @param request The current HTTP request. - * @param response The current HTTP response. - * @param authResult The authentication result if authentication succeeded, or `null` if no - * authentication was performed. - */ - protected open fun afterAuth( - request: HttpServletRequest, - response: HttpServletResponse, - authResult: AuthResult?, - ) {} - - protected data class AuthResult(val user: OgiriUser, val authHeader: AuthHeader?) - - /** - * Extract authentication header values, falling back to parsing a Bearer token from the - * Authorization header. - * - * Checks for authentication in this order: individual headers (access-token, client, uid, expiry, - * token-type), cookies, then a Base64-encoded JSON Bearer token. - * - * @return AuthHeader containing parsed authentication fields, or an empty AuthHeader if no - * authentication information is found. - */ - protected open fun extractAuthHeaderWithBearer(request: HttpServletRequest): AuthHeader { - val headerToken = request.extractAuthHeader() - if (headerToken.isValid()) return headerToken - - val authHeader = request.getHeader("Authorization") ?: return headerToken - val fields = - parseBearerToken(authHeader, properties.auth.maxBearerTokenSize) ?: return headerToken - - return AuthHeader( - accessToken = fields["access-token"], - client = fields["client"], - uid = fields["uid"], - expiry = fields["expiry"], - kind = fields["token-type"], - ) - } - - /** - * Authenticates the incoming HTTP request using APP tokens and produces an authentication result. - * - * Attempts to extract and validate an auth header (or Bearer token), validate client and uid - * identifiers, ensure the token kind is APP, load and verify the user, and decide whether to - * rotate or extend token buffers. - * - * @param request The HTTP servlet request to authenticate. - * @return An AuthResult containing the authenticated OgiriUser and optionally a refreshed - * AuthHeader, or `null` if no valid authentication header was present. - * @throws AuthenticationException If client or uid identifiers are invalid, the token kind is not - * APP, the user cannot be loaded, or the access token is invalid. - */ - @Throws(AuthenticationException::class) - protected open fun authenticateRequest(request: HttpServletRequest): AuthResult? { - beforeAuth(request) - val headerToken = extractAuthHeaderWithBearer(request) - if (!headerToken.isValid()) return null - - val requestStartedAt = Instant.now() - val client = - headerToken.client?.also { validateIdentifier(it, "error.auth.bad_client_id") } - ?: throw BadCredentialsException("error.auth.bad_client_id") - val uid = - headerToken.uid?.takeIf { it.isNotBlank() } - ?: throw BadCredentialsException("error.auth.bad_uid") - ensureAppToken(headerToken.kind) - - val user = - userDirectory.loadUserByUsername(uid) as? OgiriUser - ?: throw BadCredentialsException("error.auth.bad_credentials") - val token = headerToken.accessToken!! - if (!tokenService.validToken(token, user, client)) { - throw BadCredentialsException("error.auth.bad_credentials") - } - - val authHeader = - if (tokenService.isBatchRequest(user, client, requestStartedAt)) { - tokenService.extendBatchBuffer(user, token, client) - null - } else { - rotateTokensIfNeeded(user, client, request) - } - - return AuthResult(user, authHeader) - } - - /** - * Determines whether a new auth token should be issued for the request and returns it when - * created. - * - * Rotation may be suppressed for safe methods when configured, and may be gated by a staleness - * threshold. - * - * @return New `AuthHeader` when a rotation occurs, `null` when no rotation is performed. - */ - private fun rotateTokensIfNeeded( - user: OgiriUser, - client: String, - request: HttpServletRequest, - ): AuthHeader? { - if (rotateOnWriteOnly && isSafeMethod(request.method)) return null - val shouldRotate = - if (rotateStaleSeconds > 0) { - tokenService.shouldRotate(user, client, rotateStaleSeconds) - } else { - true - } - return if (shouldRotate) tokenService.createNewAuthToken(user.getOgiriUserId(), client, request) - else null - } - - /** - * Determines whether an HTTP method is considered safe (no side effects). - * - * @return `true` if the method equals `GET` or `HEAD` (case-insensitive), `false` otherwise. - */ - private fun isSafeMethod(method: String): Boolean = - method.equals("GET", ignoreCase = true) || method.equals("HEAD", ignoreCase = true) - - /** - * Validates that the provided token kind represents an APP token. - * - * @param kind Nullable token kind string; if null a default token type will be used. - * @throws BadCredentialsException Thrown with message "error.auth.bad_token_type" when the token - * kind is not `APP`. - */ - private fun ensureAppToken(kind: String?) { - try { - val tokenKind = OgiriTokenType.ofOrDefault(kind) - if (tokenKind != OgiriTokenType.APP) - throw BadCredentialsException("error.auth.bad_token_type") - } catch (e: IllegalArgumentException) { - throw BadCredentialsException("error.auth.bad_token_kind", e) - } - } - - private fun validateIdentifier(value: String, errorCode: String) { - if (!identifierPolicy.isValid(value)) throw BadCredentialsException(errorCode) - } - - /** - * Create an Authentication token representing the given user for the current request. - * - * @param user The authenticated OgiriUser to set as the principal. - * @param request The HTTP request used to populate authentication details. - * @return A `UsernamePasswordAuthenticationToken` with the user's authorities, masked - * credentials, and request-derived details. - */ - private fun buildAuthentication( - user: OgiriUser, - request: HttpServletRequest, - ) = - UsernamePasswordAuthenticationToken(user, "[PROTECTED_PASSWORD]", user.authorities).apply { - details = WebAuthenticationDetailsSource().buildDetails(request) - } -} diff --git a/ogiri-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/ogiri-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json deleted file mode 100644 index ee5a58b..0000000 --- a/ogiri-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "properties": [ - { - "name": "ogiri.security.register-filter", - "type": "java.lang.Boolean", - "description": "Enable automatic registration of the ogiri SecurityFilterChain bean.", - "defaultValue": true - }, - { - "name": "ogiri.auth.register-token-service", - "type": "java.lang.Boolean", - "description": "Enable auto-registration of the default OgiriTokenService.", - "defaultValue": true - }, - { - "name": "ogiri.auth.max-clients", - "type": "java.lang.Long", - "description": "Maximum number of active APP tokens per user. Oldest tokens are revoked when limit is exceeded.", - "defaultValue": 10 - }, - { - "name": "ogiri.auth.batch-grace-seconds", - "type": "java.lang.Long", - "description": "Grace period (seconds) for detecting batch requests. Prevents token thrashing from rapid consecutive requests.", - "defaultValue": 5 - }, - { - "name": "ogiri.auth.token-lifespan-days", - "type": "java.lang.Long", - "description": "Default token lifetime in days for new APP tokens.", - "defaultValue": 14 - }, - { - "name": "ogiri.auth.rotate-on-write-only", - "type": "java.lang.Boolean", - "description": "Only rotate tokens on mutating HTTP requests (POST, PUT, DELETE, PATCH). GET requests update lastUsedAt but do not trigger rotation.", - "defaultValue": false - }, - { - "name": "ogiri.auth.rotate-stale-seconds", - "type": "java.lang.Long", - "description": "Force token rotation if token exceeds this age (seconds). Set to 0 to disable staleness-based rotation.", - "defaultValue": 0 - }, - { - "name": "ogiri.auth.max-bearer-token-size", - "type": "java.lang.Integer", - "description": "Maximum allowed size for bearer tokens in bytes. Prevents memory exhaustion attacks.", - "defaultValue": 8192 - }, - { - "name": "ogiri.cleanup.enabled", - "type": "java.lang.Boolean", - "description": "Enable the scheduled OgiriTokenCleanupJob for automatic expired token deletion.", - "defaultValue": true - }, - { - "name": "ogiri.cleanup.interval-ms", - "type": "java.lang.Long", - "description": "Interval in milliseconds between cleanup job executions.", - "defaultValue": 21600000 - }, - { - "name": "ogiri.cleanup.batch-size", - "type": "java.lang.Integer", - "description": "Batch size for token cleanup operations. Tokens are deleted in batches to avoid overwhelming the database.", - "defaultValue": 1000 - }, - { - "name": "ogiri.cookies.enabled", - "type": "java.lang.Boolean", - "description": "Enable setting authentication cookies in responses.", - "defaultValue": true - }, - { - "name": "ogiri.cookies.secure", - "type": "java.lang.Boolean", - "description": "Set the Secure flag on cookies. When true, cookies are only sent over HTTPS.", - "defaultValue": true - }, - { - "name": "ogiri.cookies.http-only", - "type": "java.lang.Boolean", - "description": "Set the HttpOnly flag on cookies. When true, cookies are not accessible via JavaScript.", - "defaultValue": true - }, - { - "name": "ogiri.cookies.same-site", - "type": "java.lang.String", - "description": "SameSite attribute for cookies. Controls when cookies are sent with cross-site requests.", - "defaultValue": "Strict" - }, - { - "name": "ogiri.cookies.path", - "type": "java.lang.String", - "description": "Cookie path attribute.", - "defaultValue": "/" - }, - { - "name": "ogiri.cache.max-size", - "type": "java.lang.Long", - "description": "Maximum number of token comparison results to cache.", - "defaultValue": 10000 - }, - { - "name": "ogiri.cache.expiry-minutes", - "type": "java.lang.Long", - "description": "Time in minutes before cached token comparison results expire.", - "defaultValue": 60 - } - ], - "hints": [ - { - "name": "ogiri.cookies.same-site", - "values": [ - { - "value": "Strict", - "description": "Only sent with same-site requests (most secure)" - }, - { - "value": "Lax", - "description": "Sent with same-site and top-level navigation" - }, - { - "value": "None", - "description": "Sent with all requests (requires Secure=true)" - } - ] - } - ] -} diff --git a/ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index fa04ba3..0000000 --- a/ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1,2 +0,0 @@ -com.quantipixels.ogiri.security.session.OgiriSessionAutoConfiguration -com.quantipixels.ogiri.security.session.OgiriSessionObservabilityAutoConfiguration diff --git a/ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.diagnostics.FailureAnalyzer.imports b/ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.diagnostics.FailureAnalyzer.imports deleted file mode 100644 index 4748f20..0000000 --- a/ogiri-core/src/main/resources/META-INF/spring/org.springframework.boot.diagnostics.FailureAnalyzer.imports +++ /dev/null @@ -1 +0,0 @@ -com.quantipixels.ogiri.security.config.OgiriMissingBeanFailureAnalyzer diff --git a/ogiri-core/src/main/resources/ogiri/db/ogiri-tokens-mongodb.js b/ogiri-core/src/main/resources/ogiri/db/ogiri-tokens-mongodb.js deleted file mode 100644 index 7a8533c..0000000 --- a/ogiri-core/src/main/resources/ogiri/db/ogiri-tokens-mongodb.js +++ /dev/null @@ -1,77 +0,0 @@ -// MongoDB collection setup for ogiri's Token storage -// Run these commands in MongoDB shell or equivalent - -// Create collection with schema validation -db.createCollection("tokens", { - validator: { - $jsonSchema: { - bsonType: "object", - required: ["userId", "client", "token", "tokenType", "expiryAt", "createdAt", "updatedAt", "tokenUpdatedAt"], - properties: { - _id: { - bsonType: "objectId", - description: "Token ID" - }, - userId: { - bsonType: "long", - description: "User identifier" - }, - client: { - bsonType: "string", - description: "Client/application identifier" - }, - token: { - bsonType: "string", - description: "Token hash (BCrypt or similar)" - }, - tokenType: { - bsonType: "string", - enum: ["app", "sub"], - description: "Token type (app or sub)" - }, - tokenSubtype: { - bsonType: ["string", "null"], - description: "Sub-token name/type (optional)" - }, - expiryAt: { - bsonType: "date", - description: "Token expiration timestamp" - }, - lastToken: { - bsonType: ["string", "null"], - description: "Previous token hash for grace period (optional)" - }, - previousToken: { - bsonType: ["string", "null"], - description: "Token before last for extended grace period (optional)" - }, - createdAt: { - bsonType: "date", - description: "When token was created" - }, - updatedAt: { - bsonType: "date", - description: "Last update timestamp" - }, - tokenUpdatedAt: { - bsonType: "date", - description: "When token/rotation last occurred" - }, - lastUsedAt: { - bsonType: ["date", "null"], - description: "When token was last used (optional)" - } - } - } - } -}); - -// Create indexes for performance -db.tokens.createIndex({ userId: 1, client: 1 }, { unique: true }); -db.tokens.createIndex({ userId: 1 }); - -// TTL index to automatically delete expired tokens after 1 hour grace period -db.tokens.createIndex( - { expiryAt: 1 }, - { expireAfterSeconds: 3600 } -); diff --git a/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-h2.sql b/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-h2.sql deleted file mode 100644 index e7ef03d..0000000 --- a/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-h2.sql +++ /dev/null @@ -1,41 +0,0 @@ --- H2 schema for ogiri's Token JPA entity (in-memory database for testing). --- Adjust the foreign key target to your user table name if it differs. -CREATE - TABLE - IF NOT EXISTS user_tokens( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - client VARCHAR(255) NOT NULL, - token_hash VARCHAR(255) NOT NULL, - token_type VARCHAR(20) NOT NULL, - token_subtype VARCHAR(64), - expiry_at TIMESTAMP(6) NOT NULL, - previous_token_hash VARCHAR(255), - last_token_hash VARCHAR(255), - token_updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_used_at TIMESTAMP(6), - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE( - user_id, - client - ) - ); - -CREATE - INDEX IF NOT EXISTS idx_user_tokens_user_id ON - user_tokens(user_id); - -CREATE - INDEX IF NOT EXISTS idx_user_tokens_expiry ON - user_tokens(expiry_at); - -CREATE - INDEX IF NOT EXISTS idx_user_tokens_user_subtype ON - user_tokens( - user_id, - token_subtype - ); - --- Optional: uncomment and point to your users table when ready. --- ALTER TABLE user_tokens ADD CONSTRAINT fk_user_tokens_user_id FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-mysql.sql b/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-mysql.sql deleted file mode 100644 index 014c9f5..0000000 --- a/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens-mysql.sql +++ /dev/null @@ -1,34 +0,0 @@ --- MySQL/MariaDB schema for ogiri's Token JPA entity. --- Adjust the foreign key target to your user table name if it differs. -CREATE - TABLE - IF NOT EXISTS user_tokens( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - client VARCHAR(255) NOT NULL, - token_hash VARCHAR(255) NOT NULL, - token_type VARCHAR(20) NOT NULL, - token_subtype VARCHAR(64), - expiry_at TIMESTAMP(6) NOT NULL, - previous_token_hash VARCHAR(255), - last_token_hash VARCHAR(255), - token_updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_used_at TIMESTAMP(6), - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP ON - UPDATE - CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_tokens_user_client( - user_id, - client - ), - INDEX idx_user_tokens_user_id(user_id), - INDEX idx_user_tokens_expiry(expiry_at), - INDEX idx_user_tokens_user_subtype( - user_id, - token_subtype - ) - ); - --- Optional: uncomment and point to your users table when ready. --- ALTER TABLE user_tokens ADD CONSTRAINT fk_user_tokens_user_id FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens.sql b/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens.sql deleted file mode 100644 index 9dce474..0000000 --- a/ogiri-core/src/main/resources/ogiri/db/ogiri-user-tokens.sql +++ /dev/null @@ -1,44 +0,0 @@ --- Default schema for ogiri's Token JPA entity. --- Adjust the foreign key target to your user table name if it differs. -CREATE - TABLE - IF NOT EXISTS user_tokens( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - client VARCHAR(255) NOT NULL, - token_hash VARCHAR(255) NOT NULL, - token_type VARCHAR(20) NOT NULL, - token_subtype VARCHAR(64), - expiry_at TIMESTAMP(6) NOT NULL, - previous_token_hash VARCHAR(255), - last_token_hash VARCHAR(255), - token_updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_used_at TIMESTAMP(6), - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - -CREATE - UNIQUE INDEX IF NOT EXISTS uk_user_tokens_user_client ON - user_tokens( - user_id, - client - ); - -CREATE - INDEX IF NOT EXISTS idx_user_tokens_user_id ON - user_tokens(user_id); - -CREATE - INDEX IF NOT EXISTS idx_user_tokens_expiry ON - user_tokens(expiry_at); - -CREATE - INDEX IF NOT EXISTS idx_user_tokens_user_subtype ON - user_tokens( - user_id, - token_subtype - ); - --- Optional: uncomment and point to your users table when ready. --- ALTER TABLE user_tokens ADD CONSTRAINT fk_user_tokens_user_id FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzerTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzerTest.kt deleted file mode 100644 index 59d503e..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriMissingBeanFailureAnalyzerTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import com.quantipixels.ogiri.security.routes.OgiriRouteRegistry -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.NoSuchBeanDefinitionException - -class OgiriMissingBeanFailureAnalyzerTest { - - private val analyzer = OgiriMissingBeanFailureAnalyzer() - - @Test - fun `should analyze missing OgiriTokenRepository`() { - val exception = NoSuchBeanDefinitionException(OgiriTokenRepository::class.java) - - val analysis = analyzer.analyze(exception) - - assertNotNull(analysis) - assertEquals("No OgiriTokenRepository bean found.", analysis!!.description) - assertTrue(analysis.action.contains("ogiri-jpa")) - assertTrue(analysis.action.contains("OgiriBaseTokenEntity")) - assertEquals(exception, analysis.cause) - } - - @Test - fun `should analyze missing OgiriUserDirectory`() { - val exception = NoSuchBeanDefinitionException(OgiriUserDirectory::class.java) - - val analysis = analyzer.analyze(exception) - - assertNotNull(analysis) - assertEquals("No OgiriUserDirectory bean found.", analysis!!.description) - assertTrue(analysis.action.contains("OgiriUserDirectory")) - assertTrue(analysis.action.contains("findByUsername")) - } - - @Test - fun `should analyze missing OgiriRouteRegistry`() { - val exception = NoSuchBeanDefinitionException(OgiriRouteRegistry::class.java) - - val analysis = analyzer.analyze(exception) - - assertNotNull(analysis) - assertEquals("No OgiriRouteRegistry bean found.", analysis!!.description) - assertTrue(analysis.action.contains("OgiriRouteRegistry")) - assertTrue(analysis.action.contains("/api/auth/login")) - } - - @Test - fun `should return null for unrelated bean types`() { - val exception = NoSuchBeanDefinitionException(String::class.java) - - val analysis = analyzer.analyze(exception) - - assertNull(analysis) - } - - @Test - fun `should return null when beanType is null`() { - // Create exception without bean type info - val exception = NoSuchBeanDefinitionException("someBean") - - val analysis = analyzer.analyze(exception) - - assertNull(analysis) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriPasswordEncoderAutoConfigurationTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriPasswordEncoderAutoConfigurationTest.kt deleted file mode 100644 index 75cb8d0..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriPasswordEncoderAutoConfigurationTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder - -class OgiriPasswordEncoderAutoConfigurationTest { - - private val autoConfiguration = OgiriSecurityAutoConfiguration() - - @Test - fun `ogiriPasswordEncoder provides bcrypt implementation`() { - val encoder = autoConfiguration.ogiriPasswordEncoder() - val rawPassword = "mySecurePassword123" - val encoded = encoder.encode(rawPassword) - - assertTrue(encoder is BCryptPasswordEncoder) - assertTrue(encoded.startsWith("\$2")) - assertTrue(encoder.matches(rawPassword, encoded)) - } - - @Test - fun `ogiriPasswordEncoder is guarded with ConditionalOnMissingBean`() { - val method = OgiriSecurityAutoConfiguration::class.java.getMethod("ogiriPasswordEncoder") - val condition = method.getAnnotation(ConditionalOnMissingBean::class.java) - - assertNotNull(condition) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationCsrfTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationCsrfTest.kt deleted file mode 100644 index 11f6993..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationCsrfTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import com.quantipixels.ogiri.security.core.DefaultIdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriUser -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestToken -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.boot.autoconfigure.AutoConfigurations -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration -import org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration -import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration -import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext -import org.springframework.boot.test.context.runner.WebApplicationContextRunner -import org.springframework.context.MessageSource -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.support.StaticMessageSource -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.security.web.DefaultSecurityFilterChain -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.csrf.CsrfFilter - -class OgiriSecurityAutoConfigurationCsrfTest { - private val contextRunner = - WebApplicationContextRunner() - .withUserConfiguration(BaseDeps::class.java) - .withConfiguration( - AutoConfigurations.of( - SecurityAutoConfiguration::class.java, - SecurityFilterAutoConfiguration::class.java, - UserDetailsServiceAutoConfiguration::class.java, - OgiriSecurityAutoConfiguration::class.java, - )) - .withPropertyValues("ogiri.cleanup.enabled=false") - - @Test - fun `auto mode enables csrf when cookies enabled and sameSite none`() { - contextRunner - .withPropertyValues( - "ogiri.security.csrf.enabled=auto", - "ogiri.cookies.enabled=true", - "ogiri.cookies.same-site=None", - ) - .run { context -> - assertTrue(context.containsBean("ogiriSecurityFilterChain")) - assertTrue(hasCsrfFilter(context)) - } - } - - @Test - fun `auto mode disables csrf when sameSite is strict`() { - contextRunner - .withPropertyValues( - "ogiri.security.csrf.enabled=auto", - "ogiri.cookies.enabled=true", - "ogiri.cookies.same-site=Strict", - ) - .run { context -> - assertTrue(context.containsBean("ogiriSecurityFilterChain")) - assertFalse(hasCsrfFilter(context)) - } - } - - @Test - fun `explicit true enables csrf regardless of cookie settings`() { - contextRunner - .withPropertyValues( - "ogiri.security.csrf.enabled=true", - "ogiri.cookies.enabled=false", - "ogiri.cookies.same-site=Strict", - ) - .run { context -> - assertTrue(context.containsBean("ogiriSecurityFilterChain")) - assertTrue(hasCsrfFilter(context)) - } - } - - @Test - fun `explicit false disables csrf even when sameSite none`() { - contextRunner - .withPropertyValues( - "ogiri.security.csrf.enabled=false", - "ogiri.cookies.enabled=true", - "ogiri.cookies.same-site=None", - ) - .run { context -> - assertTrue(context.containsBean("ogiriSecurityFilterChain")) - assertFalse(hasCsrfFilter(context)) - } - } - - private fun hasCsrfFilter(context: AssertableWebApplicationContext): Boolean { - val chain = context.getBean("ogiriSecurityFilterChain", SecurityFilterChain::class.java) - val filters = (chain as DefaultSecurityFilterChain).filters - return filters.any { it is CsrfFilter } - } - - @Configuration - class BaseDeps { - @Bean fun messageSource(): MessageSource = StaticMessageSource() - - @Bean - fun passwordEncoder(): PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - - @Bean fun identifierPolicy() = DefaultIdentifierPolicy() - - @Bean fun tokenRepository(): OgiriTokenRepository = InMemoryTokenRepository() - - @Bean - fun userDirectory(): OgiriUserDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String): OgiriUser { - throw UnsupportedOperationException() - } - - override fun findById(id: Long): OgiriUser? = null - - override fun findByEmail(email: String): OgiriUser? = null - - override fun findByUsername(username: String): OgiriUser? = null - - override fun recordSuccessfulLogin(userId: Long) {} - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationWiringTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationWiringTest.kt deleted file mode 100644 index c6d43fe..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/config/OgiriSecurityAutoConfigurationWiringTest.kt +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.config - -import com.quantipixels.ogiri.security.core.DefaultIdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriUser -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestToken -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import com.quantipixels.ogiri.security.tokens.OgiriTokenType -import java.time.Instant -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.MessageSource -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Primary -import org.springframework.context.support.StaticMessageSource -import org.springframework.security.crypto.password.PasswordEncoder - -class OgiriSecurityAutoConfigurationWiringTest { - private val contextRunner = - ApplicationContextRunner() - .withUserConfiguration(BaseDeps::class.java) - .withConfiguration( - org.springframework.boot.autoconfigure.AutoConfigurations.of( - OgiriSecurityAutoConfiguration::class.java)) - .withPropertyValues( - "ogiri.security.register-filter=false", - "ogiri.cleanup.enabled=false", - ) - - companion object { - fun createTestTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: DefaultIdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ): OgiriTokenService = - object : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.label, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - } - - @Test - fun `should not create default token service when user provides one`() { - contextRunner.withUserConfiguration(UserProvidedTokenService::class.java).run { context -> - val tokenServices = context.getBeansOfType(OgiriTokenService::class.java) - org.junit.jupiter.api.Assertions.assertEquals(1, tokenServices.size) - } - } - - @Test - fun `should resolve primary token service when multiple exist`() { - contextRunner.withUserConfiguration(TwoTokenServicesOnePrimary::class.java).run { context -> - org.junit.jupiter.api.Assertions.assertTrue( - context.containsBean("ogiriTokenAuthenticationFilter")) - } - } - - @Test - fun `should fail with clear message when multiple token services and none primary`() { - contextRunner.withUserConfiguration(TwoTokenServicesNoPrimary::class.java).run { context -> - org.junit.jupiter.api.Assertions.assertTrue(context.startupFailure != null) - org.junit.jupiter.api.Assertions.assertTrue( - context.startupFailure!!.message!!.contains("Multiple OgiriTokenService beans found")) - } - } - - @Test - fun `no OgiriAuditHook bean in context when none provided`() { - contextRunner.run { context -> - org.junit.jupiter.api.Assertions.assertEquals( - 0, context.getBeansOfType(OgiriAuditHook::class.java).size) - } - } - - @Test - fun `no OgiriRateLimitHook bean in context when none provided`() { - contextRunner.run { context -> - org.junit.jupiter.api.Assertions.assertEquals( - 0, context.getBeansOfType(OgiriRateLimitHook::class.java).size) - } - } - - @Test - fun `custom OgiriAuditHook is the only hook bean when provided`() { - contextRunner.withUserConfiguration(UserProvidedAuditHook::class.java).run { context -> - val hooks = context.getBeansOfType(OgiriAuditHook::class.java) - org.junit.jupiter.api.Assertions.assertEquals(1, hooks.size) - org.junit.jupiter.api.Assertions.assertTrue( - context.containsBean("ogiriTokenService"), - "Token service should start when custom audit hook is present", - ) - } - } - - @Configuration - class BaseDeps { - @Bean fun messageSource(): MessageSource = StaticMessageSource() - - @Bean - fun passwordEncoder(): PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - - @Bean fun identifierPolicy() = DefaultIdentifierPolicy() - - @Bean fun tokenRepository(): OgiriTokenRepository = InMemoryTokenRepository() - - @Bean - fun userDirectory(): OgiriUserDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String): OgiriUser { - throw UnsupportedOperationException() - } - - override fun findById(id: Long): OgiriUser? = null - - override fun findByEmail(email: String): OgiriUser? = null - - override fun findByUsername(username: String): OgiriUser? = null - - override fun recordSuccessfulLogin(userId: Long) {} - } - } - - @Configuration - class UserProvidedTokenService { - @Bean - fun myTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: DefaultIdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ): OgiriTokenService = - createTestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) - } - - @Configuration - class TwoTokenServicesOnePrimary { - @Bean - @Primary - fun tokenServiceA( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: DefaultIdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ): OgiriTokenService = - createTestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) - - @Bean - fun tokenServiceB( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: DefaultIdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ): OgiriTokenService = - createTestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) - } - - @Configuration - class TwoTokenServicesNoPrimary { - @Bean - fun tokenServiceA( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: DefaultIdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ): OgiriTokenService = - createTestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) - - @Bean - fun tokenServiceB( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: DefaultIdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ): OgiriTokenService = - createTestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) - } - - @Configuration - class UserProvidedAuditHook { - @Bean fun testAuditHook(): OgiriAuditHook = object : OgiriAuditHook {} - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderCookieTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderCookieTest.kt deleted file mode 100644 index 026dae1..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderCookieTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletResponse - -class AuthHeaderCookieTest { - - private lateinit var response: MockHttpServletResponse - private lateinit var cookieConfig: OgiriConfigurationProperties.CookieProperties - - @BeforeEach - fun setUp() { - response = MockHttpServletResponse() - cookieConfig = - OgiriConfigurationProperties.CookieProperties().apply { - enabled = true - secure = true - httpOnly = true - sameSite = "Strict" - path = "/" - } - } - - @Nested - inner class AppendAuthCookiesTests { - - @Test - fun `sets expected cookies and security attributes`() { - val authHeader = - AuthHeader( - accessToken = "test-token", - client = "test-client", - uid = "test-user", - expiry = "1234567890", - ) - cookieConfig.path = "/api" - cookieConfig.sameSite = "Lax" - - response.appendAuthCookies(authHeader, cookieConfig) - - assertEquals(4, response.cookies.size) - assertEquals("test-token", response.cookies.find { it.name == ACCESS_TOKEN }?.value) - assertEquals("test-client", response.cookies.find { it.name == CLIENT }?.value) - assertEquals("test-user", response.cookies.find { it.name == UID }?.value) - assertEquals("1234567890", response.cookies.find { it.name == EXPIRY }?.value) - response.cookies.forEach { cookie -> - assertTrue(cookie.isHttpOnly) - assertTrue(cookie.secure) - assertEquals("/api", cookie.path) - assertEquals("Lax", cookie.getAttribute("SameSite")) - } - } - - @Test - fun `skips cookies for blank fields`() { - val authHeader = AuthHeader(accessToken = "", client = "client", uid = null, expiry = "12345") - - response.appendAuthCookies(authHeader, cookieConfig) - - assertNull(response.cookies.find { it.name == ACCESS_TOKEN }) - assertNull(response.cookies.find { it.name == UID }) - assertEquals(2, response.cookies.size) - assertEquals("client", response.cookies.find { it.name == CLIENT }?.value) - assertEquals("12345", response.cookies.find { it.name == EXPIRY }?.value) - } - } - - @Nested - inner class AppendAuthHeadersWithCookiesTests { - - @Test - fun `sets both headers and cookies when enabled`() { - val authHeader = - AuthHeader(accessToken = "token", client = "client", uid = "user", expiry = "12345") - cookieConfig.enabled = true - - response.appendAuthHeaders(authHeader, cookieConfig) - - assertEquals("token", response.getHeader(ACCESS_TOKEN)) - assertEquals("client", response.getHeader(CLIENT)) - assertEquals("user", response.getHeader(UID)) - assertEquals(4, response.cookies.size) - assertEquals("token", response.cookies.find { it.name == ACCESS_TOKEN }?.value) - } - - @Test - fun `does not set cookies when cookieConfig disabled`() { - val authHeader = - AuthHeader(accessToken = "token", client = "client", uid = "user", expiry = "12345") - cookieConfig.enabled = false - - response.appendAuthHeaders(authHeader, cookieConfig) - - assertEquals(0, response.cookies.size) - } - - @Test - fun `does not set cookies when cookieConfig null`() { - val authHeader = - AuthHeader(accessToken = "token", client = "client", uid = "user", expiry = "12345") - - response.appendAuthHeaders(authHeader, null) - - assertEquals(0, response.cookies.size) - } - - @Test - fun `null authHeaders leaves response unchanged`() { - response.appendAuthHeaders(null, cookieConfig) - - assertEquals(0, response.cookies.size) - assertNull(response.getHeader(ACCESS_TOKEN)) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderTest.kt deleted file mode 100644 index e682106..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/core/AuthHeaderTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.core - -import java.util.Base64 -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse - -class AuthHeaderTest { - @Test - fun `appendAuthHeaders emits authorization and sub token payload`() { - val authHeader = - AuthHeader( - accessToken = "token-123", - client = "client-a", - uid = "user@example.com", - expiry = "2030-01-01T00:00:00Z", - kind = "app", - subTokens = - mapOf( - "device" to - SubTokenHeader( - client = "client-a.device", - token = "sub123", - expiry = "2030-01-01T00:00:00Z", - ), - ), - ) - val response = MockHttpServletResponse() - - response.appendAuthHeaders(authHeader) - - assertEquals("token-123", response.getHeader(ACCESS_TOKEN)) - assertNotNull(response.getHeader("Authorization")) - assertNotNull(response.getHeader("device")) - } - - @Test - fun `extractAuthHeader reads primary headers`() { - val response = MockHttpServletResponse() - val authHeader = - AuthHeader( - accessToken = "token-abc", - client = "client-b", - uid = "user", - expiry = "2030-01-01T00:00:00Z", - kind = "app", - ) - response.appendAuthHeaders(authHeader) - - val encoded = response.getHeader("Authorization")!!.removePrefix("Bearer ").trim() - val decoded = String(Base64.getDecoder().decode(encoded), Charsets.UTF_8) - val request = MockHttpServletRequest() - request.addHeader(ACCESS_TOKEN, "token-abc") - request.addHeader(CLIENT, "client-b") - request.addHeader(UID, "user") - request.addHeader(EXPIRY, "2030-01-01T00:00:00Z") - request.addHeader(ACCESS_TOKEN_KIND, "app") - - val extracted = request.extractAuthHeader() - - assertEquals("token-abc", extracted.accessToken) - assertEquals("client-b", extracted.client) - assertEquals("user", extracted.uid) - assertEquals("2030-01-01T00:00:00Z", extracted.expiry) - assertEquals("app", extracted.kind) - assertNotNull(decoded) - } - - @Nested - inner class ParseBearerTokenTests { - - @Test - fun `parseBearerToken parses valid token`() { - val payload = - mapOf( - ACCESS_TOKEN to "token-123", - CLIENT to "client-a", - UID to "user@example.com", - EXPIRY to "2030-01-01T00:00:00Z", - ) - val json = JsonCodec.mapper.writeValueAsString(payload) - val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - - val result = parseBearerToken("Bearer $encoded") - - assertNotNull(result) - assertEquals("token-123", result!![ACCESS_TOKEN]) - assertEquals("client-a", result[CLIENT]) - assertEquals("user@example.com", result[UID]) - } - - @Test - fun `parseBearerToken parses token without Bearer prefix`() { - val payload = mapOf(ACCESS_TOKEN to "token-456") - val json = JsonCodec.mapper.writeValueAsString(payload) - val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - - val result = parseBearerToken(encoded) - - assertNotNull(result) - assertEquals("token-456", result!![ACCESS_TOKEN]) - } - - @Test - fun `parseBearerToken returns null for invalid base64`() { - val result = parseBearerToken("Bearer not-valid-base64!!!") - - assertNull(result) - } - - @Test - fun `parseBearerToken returns null for invalid JSON`() { - val encoded = Base64.getEncoder().encodeToString("not json".toByteArray(Charsets.UTF_8)) - - val result = parseBearerToken("Bearer $encoded") - - assertNull(result) - } - - @Test - fun `parseBearerToken rejects token exceeding max size`() { - // Create a token larger than DEFAULT_MAX_BEARER_TOKEN_SIZE - val largePayload = "x".repeat(DEFAULT_MAX_BEARER_TOKEN_SIZE + 1) - - val result = parseBearerToken("Bearer $largePayload") - - assertNull(result) - } - - @Test - fun `parseBearerToken accepts token at max size boundary`() { - // Create a valid token that's just under the limit - val payload = mapOf(ACCESS_TOKEN to "a".repeat(100)) - val json = JsonCodec.mapper.writeValueAsString(payload) - val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - - // Ensure this is under the limit - assertTrue(encoded.length <= DEFAULT_MAX_BEARER_TOKEN_SIZE) - - val result = parseBearerToken("Bearer $encoded") - - assertNotNull(result) - } - - @Test - fun `parseBearerToken rejects token where decoded content exceeds max size`() { - // Create a payload that when decoded exceeds max size but encoded might be close - // Base64 expands by ~33%, so we need content that's large enough after decoding - val largeValue = "x".repeat(DEFAULT_MAX_BEARER_TOKEN_SIZE + 1000) - val payload = mapOf("data" to largeValue) - val json = JsonCodec.mapper.writeValueAsString(payload) - val encoded = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - - // If the encoded version is already too large, it will be rejected at that stage - // Otherwise, the decoded JSON size check will catch it - val result = parseBearerToken("Bearer $encoded") - - assertNull(result) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDeciderTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDeciderTest.kt deleted file mode 100644 index 3d75e54..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/AuthenticationBypassDeciderTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.helpers - -import com.quantipixels.ogiri.security.routes.OgiriRoute -import com.quantipixels.ogiri.security.routes.OgiriRouteCatalog -import com.quantipixels.ogiri.security.routes.OgiriRouteRegistry -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.http.HttpMethod -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.util.matcher.AntPathRequestMatcher - -class AuthenticationBypassDeciderTest { - @AfterEach - fun clearContext() { - SecurityContextHolder.clearContext() - } - - @Test - fun `bypasses authenticated requests`() { - SecurityContextHolder.getContext().authentication = - UsernamePasswordAuthenticationToken("user", "pw", listOf(SimpleGrantedAuthority("USER"))) - - val catalog = OgiriRouteCatalog(emptyList()) - val decider = AuthenticationBypassDecider(catalog) - - val request = MockHttpServletRequest("GET", "/any") - assertTrue(decider.canSkip(request)) - } - - @Test - fun `bypasses whitelisted actuator route`() { - val catalog = OgiriRouteCatalog(emptyList()) - val matcher = AntPathRequestMatcher("/actuator/health") - val decider = AuthenticationBypassDecider(catalog, matcher) - val request = - MockHttpServletRequest("GET", "/actuator/health").apply { servletPath = "/actuator/health" } - - assertTrue(decider.canSkip(request)) - } - - @Test - fun `bypasses OPTIONS preflight`() { - val catalog = OgiriRouteCatalog(emptyList()) - val decider = AuthenticationBypassDecider(catalog) - val request = MockHttpServletRequest("OPTIONS", "/api/anything") - - assertTrue(decider.canSkip(request)) - } - - @Test - fun `bypasses public route from catalog`() { - val registry = - object : OgiriRouteRegistry { - override fun routes(): List = - listOf( - OgiriRoute.get("/public", useAuth = false), - OgiriRoute.get("/private", useAuth = true)) - } - val catalog = OgiriRouteCatalog(listOf(registry)) - val decider = AuthenticationBypassDecider(catalog) - - val publicRequest = MockHttpServletRequest("GET", "/public") - val privateRequest = MockHttpServletRequest("GET", "/private") - - assertTrue(decider.canSkip(publicRequest)) - assertFalse(decider.canSkip(privateRequest)) - } - - @Test - fun `route catalog matches templated paths`() { - val registry = - object : OgiriRouteRegistry { - override fun routes(): List = - listOf(OgiriRoute.get("/users/{id}", useAuth = false)) - } - val catalog = OgiriRouteCatalog(listOf(registry)) - - assertTrue(catalog.isPublicRoute("/users/123", HttpMethod.GET)) - assertFalse(catalog.isPublicRoute("/users/123", HttpMethod.POST)) - assertFalse(catalog.isPublicRoute("/projects/123", HttpMethod.GET)) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpersTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpersTest.kt deleted file mode 100644 index c68217c..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/helpers/SecurityHelpersTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.helpers - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletRequest - -class SecurityHelpersTest { - - @Nested - inner class IsValidIpTests { - - @Test - fun `valid IPv4 addresses return true`() { - assertTrue(SecurityHelpers.isValidIp("192.168.1.1")) - assertTrue(SecurityHelpers.isValidIp("10.0.0.1")) - assertTrue(SecurityHelpers.isValidIp("127.0.0.1")) - assertTrue(SecurityHelpers.isValidIp("8.8.8.8")) - assertTrue(SecurityHelpers.isValidIp("0.0.0.0")) - assertTrue(SecurityHelpers.isValidIp("255.255.255.255")) - } - - @Test - fun `invalid IPv4 addresses return false`() { - // Note: The regex doesn't validate value ranges, so 256.1.1.1 matches - // These are clearly invalid formats that don't match the pattern - assertFalse(SecurityHelpers.isValidIp("192.168.1")) - assertFalse(SecurityHelpers.isValidIp("192.168.1.1.1")) - assertFalse(SecurityHelpers.isValidIp("not-an-ip")) - assertFalse(SecurityHelpers.isValidIp("192.168.1.")) - assertFalse(SecurityHelpers.isValidIp(".192.168.1.1")) - assertFalse(SecurityHelpers.isValidIp("abc.def.ghi.jkl")) - } - - @Test - fun `valid IPv6 addresses return true`() { - // Loopback - assertTrue(SecurityHelpers.isValidIp("::1")) - // Unspecified address - assertTrue(SecurityHelpers.isValidIp("::")) - // Standard IPv6 format with multiple segments - assertTrue(SecurityHelpers.isValidIp("2001:0db8:0000:0000:0000:0000:0000:0001")) - assertTrue(SecurityHelpers.isValidIp("fe80:0000:0000:0000:0000:0000:0000:0001")) - // Compressed IPv6 (various positions) - assertTrue(SecurityHelpers.isValidIp("2001:db8::1")) - assertTrue(SecurityHelpers.isValidIp("2001:db8:85a3::8a2e:370:7334")) - } - - @Test - fun `IPv4-mapped IPv6 addresses are valid`() { - // IPv4-mapped IPv6 is valid (Java parses it as Inet4Address internally but it's still valid) - // Note: InetAddress.getByName("::ffff:192.168.1.1") returns Inet4Address - assertTrue(SecurityHelpers.isValidIp("::ffff:192.168.1.1")) - } - - @Test - fun `IPv6 with zone IDs are valid`() { - // Link-local with zone ID (commonly used in network interfaces) - assertTrue(SecurityHelpers.isValidIp("fe80::1%eth0")) - assertTrue(SecurityHelpers.isValidIp("fe80::1%en0")) - assertTrue(SecurityHelpers.isValidIp("fe80::1%1")) - } - - @Test - fun `invalid IPv6 addresses return false`() { - // Contains invalid hex character 'g' - assertFalse(SecurityHelpers.isValidIp("gggg:0000:0000:0000:0000:0000:0000:0001")) - // Invalid format - too few colons - assertFalse(SecurityHelpers.isValidIp("not:an:ipv6")) - // Invalid - too many segments - assertFalse(SecurityHelpers.isValidIp("1:2:3:4:5:6:7:8:9")) - // Invalid - empty segments without proper compression - assertFalse(SecurityHelpers.isValidIp("1::2::3")) - // Invalid - just colons (was previously accepted by regex) - assertFalse(SecurityHelpers.isValidIp("::::::::")) - } - - @Test - fun `IPv4 addresses with out-of-range octets are invalid`() { - // InetAddress correctly rejects these - assertFalse(SecurityHelpers.isValidIp("256.1.1.1")) - assertFalse(SecurityHelpers.isValidIp("192.168.1.256")) - assertFalse(SecurityHelpers.isValidIp("999.999.999.999")) - } - - @Test - fun `localhost is valid`() { - assertTrue(SecurityHelpers.isValidIp("localhost")) - } - - @Test - fun `empty string is invalid`() { - assertFalse(SecurityHelpers.isValidIp("")) - } - } - - @Nested - inner class GetClientIPTests { - - @Test - fun `returns remoteAddr directly`() { - val request = MockHttpServletRequest("GET", "/test").apply { remoteAddr = "192.168.1.100" } - assertEquals("192.168.1.100", SecurityHelpers.getClientIP(request)) - } - - @Test - fun `ignores X-Forwarded-For header`() { - val request = - MockHttpServletRequest("GET", "/test").apply { - addHeader("X-Forwarded-For", "1.1.1.1") - remoteAddr = "127.0.0.1" - } - assertEquals("127.0.0.1", SecurityHelpers.getClientIP(request)) - } - - @Test - fun `ignores X-Real-IP header`() { - val request = - MockHttpServletRequest("GET", "/test").apply { - addHeader("X-Real-IP", "2.2.2.2") - remoteAddr = "127.0.0.1" - } - assertEquals("127.0.0.1", SecurityHelpers.getClientIP(request)) - } - - @Test - fun `returns default MockHttpServletRequest remoteAddr when not set`() { - val request = MockHttpServletRequest("GET", "/test") - // MockHttpServletRequest defaults to "127.0.0.1" - assertEquals("127.0.0.1", SecurityHelpers.getClientIP(request)) - } - } - - @Nested - inner class IsPreflightTests { - - @Test - fun `OPTIONS method is preflight`() { - assertTrue(SecurityHelpers.isPreflight("OPTIONS")) - assertTrue(SecurityHelpers.isPreflight("options")) - assertTrue(SecurityHelpers.isPreflight("Options")) - } - - @Test - fun `other methods are not preflight`() { - assertFalse(SecurityHelpers.isPreflight("GET")) - assertFalse(SecurityHelpers.isPreflight("POST")) - assertFalse(SecurityHelpers.isPreflight("PUT")) - assertFalse(SecurityHelpers.isPreflight("DELETE")) - } - - @Test - fun `null method returns false`() { - assertFalse(SecurityHelpers.isPreflight(null)) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriBearerAuthenticationConverterTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriBearerAuthenticationConverterTest.kt deleted file mode 100644 index d7a64fa..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriBearerAuthenticationConverterTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.security.authentication.BadCredentialsException - -class OgiriBearerAuthenticationConverterTest { - private val converter = OgiriBearerAuthenticationConverter(128) - - @Test - fun `scheme is case insensitive and exactly one opaque credential is accepted`() { - val request = MockHttpServletRequest() - val generated = com.quantipixels.ogiri.session.OpaqueTokenCodec().generate() - request.addHeader( - HttpHeaders.AUTHORIZATION, - "bEaReR ${generated.selector}.${generated.verifier}", - ) - assertNotNull(converter.convert(request)) - } - - @Test - fun `minimum configured size accepts a default credential`() { - assertThrows(IllegalArgumentException::class.java) { - OgiriBearerAuthenticationConverter(OpaqueTokenCodec.MIN_CREDENTIAL_CHARS - 1) - } - val generated = OpaqueTokenCodec().generate() - val request = - MockHttpServletRequest().apply { - addHeader( - HttpHeaders.AUTHORIZATION, - "Bearer ${generated.selector}.${generated.verifier}", - ) - } - - assertNotNull( - OgiriBearerAuthenticationConverter(OpaqueTokenCodec.MIN_CREDENTIAL_CHARS).convert(request)) - } - - @Test - fun `duplicate headers are rejected`() { - val request = MockHttpServletRequest() - request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer first.value") - request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer second.value") - assertThrows(BadCredentialsException::class.java) { converter.convert(request) } - } - - @Test - fun `whitespace JSON and oversized values are rejected as credentials`() { - listOf( - "Bearer value with-space", - "Bearer {\"token\":true}", - "Bearer ${"a".repeat(200)}", - ) - .forEach { value -> - val request = MockHttpServletRequest() - request.addHeader(HttpHeaders.AUTHORIZATION, value) - assertThrows(BadCredentialsException::class.java) { converter.convert(request) } - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriCookieSecurityTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriCookieSecurityTest.kt deleted file mode 100644 index 1bb988a..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriCookieSecurityTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.test.InMemorySessionStore -import jakarta.servlet.http.Cookie -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.annotation.Bean -import org.springframework.security.core.userdetails.User -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.provisioning.InMemoryUserDetailsManager -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.post -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RestController - -@SpringBootTest( - classes = [OgiriCookieSecurityTest.TestApplication::class], - properties = - [ - "ogiri.session.enabled=true", - "ogiri.session.transport=COOKIE", - "ogiri.session.token-hash.current-key-id=test", - "ogiri.session.token-hash.keys.test=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "ogiri.session.public-paths[0]=/public", - ], -) -@AutoConfigureMockMvc -class OgiriCookieSecurityTest { - @Autowired private lateinit var mockMvc: MockMvc - @Autowired private lateinit var sessions: SessionManager - - @Test - fun `cookie authenticated mutation requires csrf token`() { - val issued = - sessions.issue( - SubjectRef(Realm("users"), SubjectId("user-42")), - ClientContext("browser"), - ) - val cookie = - Cookie( - "__Host-ogiri-session", - issued.credential.encoded(com.quantipixels.ogiri.session.OpaqueTokenCodec()), - ) - val bootstrap = mockMvc.get("/public").andExpect { status { isOk() } }.andReturn().response - val csrfCookie = bootstrap.getCookie("XSRF-TOKEN") - assertNotNull(csrfCookie) - - mockMvc.post("/protected") { cookie(cookie) }.andExpect { status { isForbidden() } } - mockMvc - .post("/protected") { - cookie(cookie, requireNotNull(csrfCookie)) - header("X-XSRF-TOKEN", csrfCookie.value) - } - .andExpect { status { isOk() } } - } - - @SpringBootApplication - class TestApplication { - @Bean fun sessionStore(): SessionStore = InMemorySessionStore() - - @Bean - fun userDetailsService(): UserDetailsService = - InMemoryUserDetailsManager( - User.withUsername("user-42").password("{noop}password").roles("USER").build()) - - @Bean fun controller(): Controller = Controller() - } - - @RestController - class Controller { - @GetMapping("/public") fun publicRoute(): String = "public" - - @PostMapping("/protected") fun protectedRoute(): String = "ok" - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriEndpointTransactionTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriEndpointTransactionTest.kt deleted file mode 100644 index 4b2b78e..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriEndpointTransactionTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.CreateSessionCommand -import com.quantipixels.ogiri.session.CreateSessionResult -import com.quantipixels.ogiri.session.HmacSha256TokenHasher -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.RevocationReason -import com.quantipixels.ogiri.session.SessionError -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.session.SubjectStatusChecker -import com.quantipixels.ogiri.test.InMemorySessionStore -import java.time.Clock -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.context.SecurityContextHolder - -class OgiriEndpointTransactionTest { - @Test - fun `store failure cannot leak credential or security context`() { - val delegate = InMemorySessionStore() - val failingStore = - object : SessionStore by delegate { - override fun create(command: CreateSessionCommand): CreateSessionResult = - throw IllegalStateException("commit failed") - } - val codec = OpaqueTokenCodec() - val sessions = - SessionManager( - failingStore, - codec, - HmacSha256TokenHasher("test", mapOf("test" to ByteArray(32) { 1 })), - SubjectStatusChecker { true }, - Clock.systemUTC(), - ) - val authenticationManager = AuthenticationManager { - UsernamePasswordAuthenticationToken.authenticated(it.name, null, emptyList()) - } - val properties = OgiriSessionProperties() - val controller = - OgiriSessionEndpointController( - authenticationManager, - sessions, - OgiriSubjectResolver { SubjectRef(Realm("users"), SubjectId(it.name)) }, - OgiriClientContextResolver { _, clientId -> - com.quantipixels.ogiri.session.ClientContext(clientId ?: "browser") - }, - OgiriRequestCredentialResolver(properties), - OgiriSessionResponseWriter(properties, codec), - null, - Clock.systemUTC(), - properties.rateLimit, - ) - val response = MockHttpServletResponse() - - assertThrows(IllegalStateException::class.java) { - controller.signIn( - SignInRequest("user-42", "password", "browser"), - MockHttpServletRequest(), - response, - ) - } - assertNull(response.getHeader(HttpHeaders.AUTHORIZATION)) - assertNull(response.getHeader(HttpHeaders.SET_COOKIE)) - assertNull(SecurityContextHolder.getContext().authentication) - } - - @Test - fun `current endpoint reports a revoked session instead of throwing a lookup error`() { - val store = InMemorySessionStore() - val codec = OpaqueTokenCodec() - val sessions = - SessionManager( - store, - codec, - HmacSha256TokenHasher("test", mapOf("test" to ByteArray(32) { 1 })), - SubjectStatusChecker { true }, - Clock.systemUTC(), - ) - val subject = SubjectRef(Realm("users"), SubjectId("user-42")) - val issued = sessions.issue(subject, ClientContext("browser")) - sessions.revokeAll(subject, RevocationReason.SIGN_OUT_ALL) - val properties = OgiriSessionProperties() - val controller = - OgiriSessionEndpointController( - AuthenticationManager { it }, - sessions, - OgiriSubjectResolver { subject }, - OgiriClientContextResolver { _, _ -> ClientContext("browser") }, - OgiriRequestCredentialResolver(properties), - OgiriSessionResponseWriter(properties, codec), - null, - Clock.systemUTC(), - properties.rateLimit, - ) - val authentication = - OgiriSessionAuthenticationToken.authenticated( - OgiriSessionPrincipal( - "user-42", - "users", - null, - issued.session.id.value, - "browser", - issued.session.version, - issued.session.familyId, - ), - emptyList(), - ) - - assertThrows(SessionError.Revoked::class.java) { controller.current(authentication) } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandlerTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandlerTest.kt deleted file mode 100644 index 3fe60da..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandlerTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.SessionError -import java.time.Duration -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus - -class OgiriProblemHandlerTest { - private val handler = OgiriProblemHandler() - - @Test - fun `session errors retain stable protocol statuses`() { - val unauthorized = handler.sessionError(SessionError.Revoked()) - val forbidden = handler.sessionError(SessionError.SubjectUnavailable()) - val conflict = handler.sessionError(SessionError.Conflict()) - - assertEquals(HttpStatus.UNAUTHORIZED, unauthorized.statusCode) - assertNotNull(unauthorized.headers.getFirst(HttpHeaders.WWW_AUTHENTICATE)) - assertEquals(HttpStatus.FORBIDDEN, forbidden.statusCode) - assertEquals(HttpStatus.CONFLICT, conflict.statusCode) - } - - @Test - fun `rate limits include a retry after header`() { - val response = handler.rateLimited(OgiriRateLimitExceeded(Duration.ofSeconds(7))) - - assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.statusCode) - assertEquals("7", response.headers.getFirst(HttpHeaders.RETRY_AFTER)) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSecureChainTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSecureChainTest.kt deleted file mode 100644 index 14558d0..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSecureChainTest.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.test.InMemorySessionStore -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertSame -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.annotation.Bean -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.userdetails.User -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.core.userdetails.UsernameNotFoundException -import org.springframework.security.provisioning.InMemoryUserDetailsManager -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.delete -import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.post -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RestController - -@SpringBootTest( - classes = [OgiriSecureChainTest.TestApplication::class], - properties = - [ - "ogiri.session.enabled=true", - "ogiri.session.token-hash.current-key-id=test", - "ogiri.session.token-hash.keys.test=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "ogiri.session.endpoints.enabled=true", - ], -) -@AutoConfigureMockMvc -class OgiriSecureChainTest { - @Autowired private lateinit var mockMvc: MockMvc - @Autowired private lateinit var sessions: SessionManager - - @Test - fun `protected controller rejects an anonymous request`() { - mockMvc.get("/protected").andExpect { status { isUnauthorized() } } - } - - @Test - fun `default public routes expose sign-in but protect session management`() { - mockMvc - .post("/auth/sign-in") { - contentType = MediaType.APPLICATION_JSON - content = """{"username":"user-42","password":"wrong","clientId":"browser"}""" - } - .andExpect { status { isUnauthorized() } } - mockMvc.get("/auth/session").andExpect { status { isUnauthorized() } } - mockMvc.get("/auth/sessions").andExpect { status { isUnauthorized() } } - mockMvc.delete("/auth/sign-out").andExpect { status { isUnauthorized() } } - } - - @Test - fun `subject checker propagates transient user store failures`() { - val failure = IllegalStateException("directory unavailable") - val checker = - OgiriSessionAutoConfiguration() - .ogiriSubjectStatusChecker(UserDetailsService { throw failure }) - - val thrown = - assertThrows(IllegalStateException::class.java) { - checker.isAllowed(SubjectRef(Realm("users"), SubjectId("user-42"))) - } - assertSame(failure, thrown) - } - - @Test - fun `subject checker rejects definitive unknown users`() { - val checker = - OgiriSessionAutoConfiguration() - .ogiriSubjectStatusChecker( - UserDetailsService { throw UsernameNotFoundException("missing") }) - - assertFalse(checker.isAllowed(SubjectRef(Realm("users"), SubjectId("missing")))) - } - - @Test - fun `issued bearer header round-trips through selected chain`() { - val issued = - sessions.issue( - SubjectRef(Realm("users"), SubjectId("user-42")), - ClientContext("browser"), - ) - val header = - "Bearer ${issued.credential.encoded(com.quantipixels.ogiri.session.OpaqueTokenCodec())}" - - mockMvc - .get("/protected") { header(HttpHeaders.AUTHORIZATION, header) } - .andExpect { - status { isOk() } - content { string("user-42") } - } - } - - @Test - fun `endpoint starter signs in lists and idempotently revokes current session`() { - val login = - mockMvc - .post("/auth/sign-in") { - contentType = MediaType.APPLICATION_JSON - content = """{"username":"user-42","password":"password","clientId":"browser"}""" - } - .andExpect { - status { isCreated() } - header { exists(HttpHeaders.AUTHORIZATION) } - header { string(HttpHeaders.CACHE_CONTROL, "no-store") } - jsonPath("$.subject") { value("user-42") } - } - .andReturn() - val authorization = login.response.getHeader(HttpHeaders.AUTHORIZATION)!! - - mockMvc - .get("/auth/sessions") { header(HttpHeaders.AUTHORIZATION, authorization) } - .andExpect { - status { isOk() } - jsonPath("$[0].current") { value(true) } - } - mockMvc - .delete("/auth/sign-out") { header(HttpHeaders.AUTHORIZATION, authorization) } - .andExpect { - status { isNoContent() } - header { doesNotExist(HttpHeaders.AUTHORIZATION) } - } - mockMvc - .get("/protected") { header(HttpHeaders.AUTHORIZATION, authorization) } - .andExpect { status { isUnauthorized() } } - } - - @SpringBootApplication - class TestApplication { - @Bean fun sessionStore(): SessionStore = InMemorySessionStore() - - @Bean - fun userDetailsService(): UserDetailsService = - InMemoryUserDetailsManager( - User.withUsername("user-42").password("{noop}password").roles("USER").build()) - - @Bean - fun authenticationManager(users: UserDetailsService): AuthenticationManager = - AuthenticationManager { request -> - val user = users.loadUserByUsername(request.name) - if (request.credentials != "password") throw BadCredentialsException("bad_credentials") - UsernamePasswordAuthenticationToken.authenticated(user, null, user.authorities) - } - - @Bean fun testController(): TestController = TestController() - } - - @RestController - class TestController { - @GetMapping("/public") fun publicRoute(): String = "public" - - @GetMapping("/protected") - fun protectedRoute(authentication: org.springframework.security.core.Authentication): String = - (authentication.principal as OgiriSessionPrincipal).subject - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanupTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanupTest.kt deleted file mode 100644 index 314d67c..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanupTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.HmacSha256TokenHasher -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.session.SubjectStatusChecker -import com.quantipixels.ogiri.test.InMemorySessionStore -import com.quantipixels.ogiri.test.OgiriFakeClock -import java.time.Duration -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler - -class OgiriSessionCleanupTest { - @Test - fun `cleanup owns a cluster lease and commits bounded pages`() { - val clock = OgiriFakeClock(Instant.parse("2026-01-01T00:00:00Z")) - val store = InMemorySessionStore() - val sessions = - SessionManager( - store, - OpaqueTokenCodec(), - HmacSha256TokenHasher("test", mapOf("test" to ByteArray(32) { 1 })), - SubjectStatusChecker { true }, - clock, - ) - repeat(5) { - sessions.issue( - SubjectRef(Realm("users"), SubjectId("subject-$it")), - ClientContext("client"), - ) - } - clock.advance(Duration.ofDays(15)) - val lease = InMemoryJobLease() - val cleanup = - OgiriSessionCleanupScheduler( - sessions, - lease, - ConcurrentTaskScheduler(), - clock, - OgiriSessionProperties.Cleanup(batchSize = 2), - ) - - cleanup.runOnce() - - assertEquals(5, cleanup.status().lastDeletedRows) - assertEquals(3, lease.acquireAttempts) - assertTrue(store.snapshot().isEmpty()) - assertFalse(lease.isHeld("session-cleanup")) - } - - @Test - fun `cleanup stops before another page when lease renewal fails`() { - val clock = OgiriFakeClock(Instant.parse("2026-01-01T00:00:00Z")) - val store = InMemorySessionStore() - val sessions = sessionManager(store, clock) - repeat(5) { - sessions.issue(SubjectRef(Realm("users"), SubjectId("renewal-$it")), ClientContext("client")) - } - clock.advance(Duration.ofDays(15)) - val lease = InMemoryJobLease(maxSuccessfulAcquisitions = 1) - val cleanup = - OgiriSessionCleanupScheduler( - sessions, - lease, - ConcurrentTaskScheduler(), - clock, - OgiriSessionProperties.Cleanup(batchSize = 2), - ) - - cleanup.runOnce() - - assertEquals(2, cleanup.status().lastDeletedRows) - assertEquals(3, store.snapshot().size) - assertEquals(2, lease.acquireAttempts) - } - - @Test - fun `cleanup respects its fixed run duration before another page`() { - val clock = OgiriFakeClock(Instant.parse("2026-01-01T00:00:00Z")) - val delegate = InMemorySessionStore() - val timedStore = - object : SessionStore by delegate { - override fun deleteExpiredPage(before: Instant, limit: Int): Int { - val deleted = delegate.deleteExpiredPage(before, limit) - clock.advance(Duration.ofMinutes(5)) - return deleted - } - } - val sessions = sessionManager(timedStore, clock) - repeat(5) { - sessions.issue(SubjectRef(Realm("users"), SubjectId("duration-$it")), ClientContext("client")) - } - clock.advance(Duration.ofDays(15)) - val lease = InMemoryJobLease() - val cleanup = - OgiriSessionCleanupScheduler( - sessions, - lease, - ConcurrentTaskScheduler(), - clock, - OgiriSessionProperties.Cleanup( - batchSize = 2, - maxRunDuration = Duration.ofMinutes(5), - ), - ) - - cleanup.runOnce() - - assertEquals(2, cleanup.status().lastDeletedRows) - assertEquals(3, delegate.snapshot().size) - assertEquals(1, lease.acquireAttempts) - } - - private fun sessionManager(store: SessionStore, clock: OgiriFakeClock): SessionManager = - SessionManager( - store, - OpaqueTokenCodec(), - HmacSha256TokenHasher("test", mapOf("test" to ByteArray(32) { 1 })), - SubjectStatusChecker { true }, - clock, - ) -} - -private class InMemoryJobLease(private val maxSuccessfulAcquisitions: Int = Int.MAX_VALUE) : - OgiriJobLease { - private val owners = ConcurrentHashMap() - var acquireAttempts: Int = 0 - private set - - @Synchronized - override fun tryAcquire(name: String, owner: String, now: Instant, until: Instant): Boolean { - acquireAttempts += 1 - if (acquireAttempts > maxSuccessfulAcquisitions) return false - val existing = owners[name] - if (existing != null && existing != owner) return false - owners[name] = owner - return true - } - - override fun release(name: String, owner: String) { - owners.remove(name, owner) - } - - fun isHeld(name: String): Boolean = owners.containsKey(name) -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionPropertiesTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionPropertiesTest.kt deleted file mode 100644 index 143227e..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionPropertiesTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test - -class OgiriSessionPropertiesTest { - @Test - fun `endpoint base path accepts canonical literal paths`() { - assertEquals( - "/api/session-auth", - OgiriSessionProperties.Endpoints(basePath = "/api/session-auth").basePath) - } - - @Test - fun `endpoint base path rejects ambiguous mappings`() { - listOf("", "auth", "/", "/auth/", "/api//auth", "/auth?internal=true", "/auth/**", "/{auth}") - .forEach { path -> - assertThrows( - IllegalArgumentException::class.java, - { OgiriSessionProperties.Endpoints(basePath = path) }, - path) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponseWriterTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponseWriterTest.kt deleted file mode 100644 index 106c3fd..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponseWriterTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.session - -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.HmacSha256TokenHasher -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.session.SubjectStatusChecker -import com.quantipixels.ogiri.test.InMemorySessionStore -import java.time.Clock -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.mock.web.MockHttpServletResponse - -class OgiriSessionResponseWriterTest { - private val issued = - SessionManager( - InMemorySessionStore(), - OpaqueTokenCodec(), - HmacSha256TokenHasher("test", mapOf("test" to ByteArray(32) { 1 })), - SubjectStatusChecker { true }, - Clock.systemUTC(), - ) - .issue( - SubjectRef(Realm("users"), SubjectId("subject")), - ClientContext("browser"), - ) - - @Test - fun `cookie mode exposes credential only through HttpOnly cookie`() { - val response = MockHttpServletResponse() - OgiriSessionResponseWriter(OgiriSessionProperties(transport = OgiriTransport.COOKIE)) - .writeCredential(response, issued) - - assertNull(response.getHeader(HttpHeaders.AUTHORIZATION)) - assertNull(response.getHeader("access-token")) - assertTrue(response.getHeader(HttpHeaders.SET_COOKIE)!!.startsWith("__Host-ogiri-session=")) - assertTrue(response.getHeader(HttpHeaders.SET_COOKIE)!!.contains("HttpOnly")) - assertEquals("no-store", response.getHeader(HttpHeaders.CACHE_CONTROL)) - } - - @Test - fun `bearer mode emits no cookie or compatibility headers`() { - val response = MockHttpServletResponse() - OgiriSessionResponseWriter(OgiriSessionProperties(transport = OgiriTransport.BEARER)) - .writeCredential(response, issued) - - assertTrue(response.getHeader(HttpHeaders.AUTHORIZATION)!!.startsWith("Bearer ")) - assertNull(response.getHeader(HttpHeaders.SET_COOKIE)) - assertNull(response.getHeader("access-token")) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/InMemoryTokenRepository.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/InMemoryTokenRepository.kt deleted file mode 100644 index 5af8261..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/InMemoryTokenRepository.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.testutil - -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import java.time.Instant -import java.util.Optional -import java.util.concurrent.atomic.AtomicLong - -/** - * In-memory TokenRepository implementation for testing. - * - * This repository stores all tokens in a mutable list, simulating a database without requiring - * actual database connections. It's useful for unit testing TokenService and other components that - * depend on TokenRepository. - * - * Thread-safe operations are supported through synchronized blocks. - */ -class InMemoryTokenRepository : OgiriTokenRepository { - private val tokens = mutableListOf() - private val idSequence = AtomicLong(1L) - private var clock: Instant = Instant.now() - - /** Removes all tokens from the repository in a thread-safe manner. */ - fun clear() { - synchronized(tokens) { tokens.clear() } - } - - fun getAllTokens(): List { - synchronized(tokens) { - return tokens.toList() - } - } - - fun getCount(): Int { - synchronized(tokens) { - return tokens.size - } - } - - /** - * Advance the repository's internal clock by one second to simulate time progression in tests. - */ - fun incrementClock() { - synchronized(tokens) { clock = clock.plusSeconds(1) } - } - - /** - * Inserts or updates a token. On insert, assigns a new id and preserves `plainToken`. On update, - * replaces the existing record. In both cases sets `updatedAt` to the repository clock for - * deterministic test time control. - */ - @Suppress("UNCHECKED_CAST") - override fun save(token: S): S { - synchronized(tokens) { - return if (token.id == 0L) { - // Insert: generate new ID and preserve transient properties - val newToken = token.copy(id = idSequence.getAndIncrement()) - newToken.plainToken = token.plainToken // Preserve transient property - newToken.updatedAt = clock // Use repository's clock for deterministic testing - tokens.add(newToken) - newToken as S - } else { - // Update: remove old, add new - tokens.removeIf { it.id == token.id } - token.updatedAt = clock // Use repository's clock for deterministic testing - tokens.add(token) - token - } - } - } - - override fun findById(id: Long): Optional { - synchronized(tokens) { - return Optional.ofNullable(tokens.find { it.id == id }) - } - } - - override fun deleteById(id: Long) { - synchronized(tokens) { tokens.removeIf { it.id == id } } - } - - override fun findByUserIdOrderByUpdatedAtDesc(userId: Long): List { - synchronized(tokens) { - return tokens.filter { it.userId == userId }.sortedByDescending { it.updatedAt } - } - } - - override fun findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc( - userId: Long, - tokenSubtype: String, - ): List { - synchronized(tokens) { - return tokens - .filter { it.userId == userId && it.tokenSubtype == tokenSubtype } - .sortedByDescending { it.updatedAt } - } - } - - override fun findByUserIdAndClient( - userId: Long, - client: String, - ): Optional { - synchronized(tokens) { - return Optional.ofNullable(tokens.find { it.userId == userId && it.client == client }) - } - } - - override fun findByUserIdAndClientIn( - userId: Long, - clients: Collection, - ): List { - synchronized(tokens) { - return tokens.filter { it.userId == userId && it.client in clients } - } - } - - override fun findByExpiryAtBefore(cutoff: Instant): List { - synchronized(tokens) { - return tokens.filter { it.expiryAt.isBefore(cutoff) } - } - } - - override fun findByTokenType(tokenType: String): List { - synchronized(tokens) { - return tokens.filter { it.tokenType.equals(tokenType, ignoreCase = true) } - } - } - - override fun deleteByUserIdAndClient( - userId: Long, - client: String, - ) { - synchronized(tokens) { tokens.removeIf { it.userId == userId && it.client == client } } - } - - override fun deleteByUserIdAndClientIn( - userId: Long, - clients: Collection, - ) { - synchronized(tokens) { tokens.removeIf { it.userId == userId && it.client in clients } } - } - - /** Delete all tokens for a user. */ - override fun deleteByUserId(userId: Long) { - synchronized(tokens) { tokens.removeIf { it.userId == userId } } - } - - override fun delete(token: TestToken) { - synchronized(tokens) { tokens.removeIf { it.id == token.id } } - } - - override fun countByUserId(userId: Long): Long { - synchronized(tokens) { - return tokens.count { it.userId == userId }.toLong() - } - } - - override fun deleteByExpiryAtBefore(cutoff: Instant): Int { - synchronized(tokens) { - val count = tokens.count { it.expiryAt.isBefore(cutoff) } - tokens.removeIf { it.expiryAt.isBefore(cutoff) } - return count - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestFixtures.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestFixtures.kt deleted file mode 100644 index 01d5913..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestFixtures.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.testutil - -import com.quantipixels.ogiri.security.spi.OgiriUser -import java.time.Instant -import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority - -/** Common test fixtures and builders for test data. */ -object TestFixtures { - /** - * Create a test OgiriUser configured for use in unit tests. - * - * @param userId The user's id to return from getOgiriUserId(); defaults to 1. - * @param username The username to return from getUsername(); defaults to "testuser". - * @return An OgiriUser with the given id and username, password `"password"`, a single - * `ROLE_USER` authority, and all account-status flags set to `true`. - */ - fun testUser( - userId: Long = 1L, - username: String = "testuser", - ): OgiriUser = - object : OgiriUser { - override fun getOgiriUserId(): Long = userId - - override fun getAuthorities(): MutableCollection = - mutableListOf(SimpleGrantedAuthority("ROLE_USER")) - - override fun getPassword(): String = "password" - - override fun getUsername(): String = username - - override fun isAccountNonExpired(): Boolean = true - - override fun isAccountNonLocked(): Boolean = true - - override fun isCredentialsNonExpired(): Boolean = true - - override fun isEnabled(): Boolean = true - } - - /** Returns a client identifier in the form `"-client"`. */ - fun testClientId(name: String = "test"): String = "$name-client" - - /** An [Instant] that is [seconds] seconds after the current time. */ - fun futureExpiry(seconds: Long = 3600): Instant = Instant.now().plusSeconds(seconds) - - /** An [Instant] that is [seconds] seconds before the current time. */ - fun pastExpiry(seconds: Long = 3600): Instant = Instant.now().minusSeconds(seconds) - - /** Hardcoded JWT-like bearer token for use in authentication-related tests. */ - fun testBearerToken(): String = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP7THsR8U" - - /** Reproducible bcrypt-like hashed token string for test fixtures. */ - fun testHashedToken(): String = "\$2a\$10\$abcdefghijklmnopqrstuvwxyz1234567890123456789012345" - - /** Builder for creating test tokens fluently. */ - class TokenBuilder( - var id: Long = 0, - var userId: Long = 1L, - var client: String = "test-client", - var token: String = testHashedToken(), - var tokenType: String = "app", - var expiryAt: Instant = futureExpiry(), - var createdAt: Instant = Instant.now(), - var updatedAt: Instant = Instant.now(), - var tokenUpdatedAt: Instant = Instant.now(), - var tokenSubtype: String? = null, - var lastToken: String? = null, - var previousToken: String? = null, - var lastUsedAt: Instant? = null, - ) { - fun withId(id: Long) = apply { this.id = id } - - fun withUserId(userId: Long) = apply { this.userId = userId } - - fun withClient(client: String) = apply { this.client = client } - - fun withToken(token: String) = apply { this.token = token } - - fun withOgiriTokenType(tokenType: String) = apply { this.tokenType = tokenType } - - fun withExpiry(expiryAt: Instant) = apply { this.expiryAt = expiryAt } - - /** Marks the token as expired by setting its expiry to a past instant. */ - fun withExpired() = apply { this.expiryAt = pastExpiry() } - - fun withTokenSubtype(tokenSubtype: String?) = apply { this.tokenSubtype = tokenSubtype } - - fun withLastToken(lastToken: String?) = apply { this.lastToken = lastToken } - - fun withPreviousToken(previousToken: String?) = apply { this.previousToken = previousToken } - - fun withLastUsedAt(lastUsedAt: Instant?) = apply { this.lastUsedAt = lastUsedAt } - - fun build(): TestToken = - TestToken( - id = id, - userId = userId, - client = client, - token = token, - tokenType = tokenType, - expiryAt = expiryAt, - createdAt = createdAt, - updatedAt = updatedAt, - tokenUpdatedAt = tokenUpdatedAt, - tokenSubtype = tokenSubtype, - lastToken = lastToken, - previousToken = previousToken, - lastUsedAt = lastUsedAt, - ) - } - - /** - * Creates a TokenBuilder preconfigured with default test values. - * - * @return A new TokenBuilder instance for constructing test tokens. - */ - fun token(): TokenBuilder = TokenBuilder() -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestToken.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestToken.kt deleted file mode 100644 index d28488d..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/testutil/TestToken.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.testutil - -import com.quantipixels.ogiri.security.tokens.OgiriBaseToken -import java.time.Instant - -/** - * Simple in-memory Token implementation for testing. - * - * This data class extends BaseToken and can be used in unit tests without requiring a database or - * JPA. It's useful for testing TokenService and TokenRepository implementations. - * - * All properties are mutable to allow TokenService to update them as needed. - */ -data class TestToken( - override var id: Long = 0, - override val userId: Long, - override val client: String, - override var token: String, - override val tokenType: String = "app", - override var expiryAt: Instant, - override val createdAt: Instant = Instant.now(), - override var updatedAt: Instant = Instant.now(), - override var tokenUpdatedAt: Instant = Instant.now(), - override var tokenSubtype: String? = null, - override var lastToken: String? = null, - override var previousToken: String? = null, - override var lastUsedAt: Instant? = null, -) : OgiriBaseToken() { - companion object { - /** Creates a [TestToken] with the minimal fields needed in most tests. */ - fun create( - userId: Long = 1L, - client: String = "test-client", - token: String = "hashed-token", - expiryAt: Instant = Instant.now().plusSeconds(3600), - ): TestToken = - TestToken( - userId = userId, - client = client, - token = token, - expiryAt = expiryAt, - ) - - /** Creates a [TestToken] with `expiryAt` set to one second before now. */ - fun expired( - userId: Long = 1L, - client: String = "test-client", - ): TestToken = - TestToken( - userId = userId, - client = client, - token = "hashed-token", - expiryAt = Instant.now().minusSeconds(1), - ) - - /** Create a token with all fields populated. */ - fun full( - id: Long = 1L, - userId: Long = 1L, - client: String = "test-client", - token: String = "hashed-token", - tokenType: String = "app", - expiryAt: Instant = Instant.now().plusSeconds(3600), - createdAt: Instant = Instant.now(), - updatedAt: Instant = Instant.now(), - tokenUpdatedAt: Instant = Instant.now(), - tokenSubtype: String? = null, - lastToken: String? = null, - previousToken: String? = null, - lastUsedAt: Instant? = null, - ): TestToken = - TestToken( - id = id, - userId = userId, - client = client, - token = token, - tokenType = tokenType, - expiryAt = expiryAt, - createdAt = createdAt, - updatedAt = updatedAt, - tokenUpdatedAt = tokenUpdatedAt, - tokenSubtype = tokenSubtype, - lastToken = lastToken, - previousToken = previousToken, - lastUsedAt = lastUsedAt, - ) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenLookupCacheTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenLookupCacheTest.kt deleted file mode 100644 index 0cab004..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenLookupCacheTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import java.time.Instant -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.security.crypto.password.PasswordEncoder - -class OgiriTokenLookupCacheTest { - private lateinit var repository: InMemoryTokenRepository - private val passwordEncoder: PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - private val identifierPolicy = - object : IdentifierPolicy { - private val counter = AtomicLong(0) - - override fun generate(): String = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() - } - - private val user = TestFixtures.testUser(userId = 1L) - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = user - - override fun findByUsername(username: String) = user - - override fun recordSuccessfulLogin(userId: Long) {} - } - - /** Simple in-memory cache that records all operations for assertion. */ - private inner class RecordingCache : OgiriTokenLookupCache { - val store = mutableMapOf() - val getCalls = mutableListOf() - val putCalls = mutableListOf() - val evictCalls = mutableListOf() - val evictAllCalls = mutableListOf() - - private fun key(userId: Long, client: String) = "$userId:$client" - - override fun get(userId: Long, client: String): TestToken? { - getCalls.add(key(userId, client)) - return store[key(userId, client)] - } - - override fun put(userId: Long, client: String, token: TestToken) { - putCalls.add(key(userId, client)) - store[key(userId, client)] = token - } - - override fun evict(userId: Long, client: String) { - evictCalls.add(key(userId, client)) - store.remove(key(userId, client)) - } - - override fun evictAll(userId: Long) { - evictAllCalls.add(userId) - store.keys.removeIf { it.startsWith("$userId:") } - } - } - - private fun createService( - cache: OgiriTokenLookupCache? = null - ): OgiriTokenService = - object : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - DefaultOgiriSubTokenRegistry(emptyList()), - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - } - }, - ) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - .also { service -> cache?.let { service.setLookupCache(it) } } - - @BeforeEach - fun setup() { - repository = InMemoryTokenRepository() - } - - @Nested - inner class NoCacheTests { - @Test - fun `service works with no lookup cache (falls through to repository)`() { - val service = createService(cache = null) - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-a") - assertNotNull(headers.accessToken) - } - } - - @Nested - inner class CachePopulationTests { - @Test - fun `token is in cache after createNewAuthToken`() { - val cache = RecordingCache() - val service = createService(cache) - - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-x") - val client = headers.client!! - - // The token should be in the cache after creation/lookup in issueSubTokens - assertNotNull(cache.store["1:$client"], "Cache should hold the token entity after creation") - } - - @Test - fun `validToken is served from cache without going to repo on second call`() { - val cache = RecordingCache() - val service = createService(cache) - - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-y") - val client = headers.client!! - val token = headers.accessToken!! - - // Confirm cache is populated - assertNotNull(cache.store["1:$client"]) - - // First validToken: cache hit → getCalls incremented, no put - cache.getCalls.clear() - cache.putCalls.clear() - - service.validToken(token, user, client) - - assertEquals(listOf("1:$client"), cache.getCalls, "Cache.get should be called once") - assertEquals(emptyList(), cache.putCalls, "No put on cache hit") - } - - @Test - fun `validToken populates cache on miss (after manual evict)`() { - val cache = RecordingCache() - val service = createService(cache) - - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-z") - val client = headers.client!! - val token = headers.accessToken!! - - // Manually evict to simulate a cold cache for this entry - cache.store.remove("1:$client") - cache.getCalls.clear() - cache.putCalls.clear() - - service.validToken(token, user, client) - - assertEquals(listOf("1:$client"), cache.getCalls, "Cache.get called on miss") - assertEquals(listOf("1:$client"), cache.putCalls, "Cache populated on miss") - } - } - - @Nested - inner class CacheEvictionTests { - @Test - fun `createOrUpdateToken evicts cache for the rotated client`() { - val cache = RecordingCache() - val service = createService(cache) - - val expiry = Instant.now().plusSeconds(3600) - service.createOrUpdateToken(user, "evict-client", expiry) - val evictsAfterCreate = cache.evictCalls.size - - service.createOrUpdateToken(user, "evict-client", expiry) - - assertEquals(evictsAfterCreate + 1, cache.evictCalls.size) - assert(cache.evictCalls.last() == "1:evict-client") - } - - @Test - fun `deleteToken(client) evicts that client from cache`() { - val cache = RecordingCache() - val service = createService(cache) - - val expiry = Instant.now().plusSeconds(3600) - service.createOrUpdateToken(user, "del-client", expiry) - cache.evictCalls.clear() - - service.deleteToken(user.getOgiriUserId(), "del-client") - - assertEquals(listOf("1:del-client"), cache.evictCalls) - assertNull(cache.store["1:del-client"]) - } - - @Test - fun `deleteAllForUser evicts all entries for that user`() { - val cache = RecordingCache() - val service = createService(cache) - - val expiry = Instant.now().plusSeconds(3600) - service.createOrUpdateToken(user, "c1", expiry) - service.createOrUpdateToken(user, "c2", expiry) - cache.evictAllCalls.clear() - - service.deleteAllForUser(user.getOgiriUserId()) - - assertEquals(listOf(1L), cache.evictAllCalls) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceHookTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceHookTest.kt deleted file mode 100644 index 946d3a9..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceHookTest.kt +++ /dev/null @@ -1,530 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.core.SecurityServiceException -import com.quantipixels.ogiri.security.spi.OgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import jakarta.servlet.http.HttpServletRequest -import java.time.Instant -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.crypto.password.PasswordEncoder - -class OgiriTokenServiceHookTest { - private lateinit var repository: InMemoryTokenRepository - private val passwordEncoder: PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - private val identifierPolicy = - object : IdentifierPolicy { - private val counter = AtomicLong(0) - - override fun generate(): String = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() - } - - private val user = TestFixtures.testUser(userId = 1L, username = "testuser") - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = user.takeIf { "testuser@example.com" == email } - - override fun findByUsername(username: String) = user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - } - - private fun defaultProperties() = - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - } - } - - private fun createService( - auditHook: OgiriAuditHook? = null, - rateLimitHook: OgiriRateLimitHook? = null, - registry: OgiriSubTokenRegistry = DefaultOgiriSubTokenRegistry(emptyList()), - ): OgiriTokenService = - TestTokenService( - repository = repository, - passwordEncoder = passwordEncoder, - userDirectory = userDirectory, - identifierPolicy = identifierPolicy, - subTokenRegistry = registry, - properties = defaultProperties(), - ) - .also { service -> - auditHook?.let { service.setAuditHook(it) } - rateLimitHook?.let { service.setRateLimitHook(it) } - } - - private inner class TestTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ) : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties, - ) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - - @BeforeEach - fun setup() { - repository = InMemoryTokenRepository() - } - - @AfterEach - fun cleanup() { - SecurityContextHolder.clearContext() - } - - @Nested - inner class NullHookFallbackTests { - @Test - fun `service works with no hooks (null uses no-op defaults)`() { - val service = createService() - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-a") - assertTrue(headers.accessToken != null) - } - - @Test - fun `service uses provided audit hook when available`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onLoginSuccess(userId: Long, client: String, ip: String?) { - calls.add("loginSuccess:$userId") - } - - override fun onLoginFailure(identifier: String, reason: String, ip: String?) { - calls.add("loginFailure:$identifier:$reason") - } - } - val service = createService(auditHook = auditHook) - service.createNewAuthToken(user.getOgiriUserId(), "client-b") - // createNewAuthToken doesn't trigger login hooks, just verifies no errors - assertTrue(calls.isEmpty()) - } - } - - @Nested - inner class AuditHookInvocationTests { - @Test - fun `verifyUser success calls onLoginSuccess`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onLoginSuccess(userId: Long, client: String, ip: String?) { - calls.add("loginSuccess:$userId:$client") - } - } - val service = createService(auditHook = auditHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - service.verifyUser(request, response, "testuser@example.com", "password") - - assertEquals(1, calls.size) - assertTrue(calls[0].startsWith("loginSuccess:1:")) - } - - @Test - fun `verifyUser failure (user not found) calls onLoginFailure`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onLoginFailure(identifier: String, reason: String, ip: String?) { - calls.add("loginFailure:$identifier:$reason") - } - } - val service = createService(auditHook = auditHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - assertThrows(SecurityServiceException::class.java) { - service.verifyUser(request, response, "nobody@example.com", "password") - } - - assertEquals(1, calls.size) - assertEquals("loginFailure:nobody@example.com:user_not_found", calls[0]) - } - - @Test - fun `verifyUser failure (bad password) calls onLoginFailure`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onLoginFailure(identifier: String, reason: String, ip: String?) { - calls.add("loginFailure:$identifier:$reason") - } - } - val service = createService(auditHook = auditHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - assertThrows(SecurityServiceException::class.java) { - service.verifyUser(request, response, "testuser@example.com", "wrong-password") - } - - assertEquals(1, calls.size) - assertEquals("loginFailure:testuser@example.com:invalid_password", calls[0]) - } - - @Test - fun `createOrUpdateToken rotation calls onTokenRotated`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onTokenRotated(userId: Long, client: String) { - calls.add("rotated:$userId:$client") - } - } - val service = createService(auditHook = auditHook) - val expiry = Instant.now().plusSeconds(3600) - - // First create (not a rotation) - service.createOrUpdateToken(user, "client-rot", expiry) - assertTrue(calls.isEmpty()) - - // Second create with same client = rotation - service.createOrUpdateToken(user, "client-rot", expiry) - assertEquals(1, calls.size) - assertEquals("rotated:1:client-rot", calls[0]) - } - - @Test - fun `revokeClient calls onTokenRevoked`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onTokenRevoked(userId: Long, client: String) { - calls.add("revoked:$userId:$client") - } - } - val service = createService(auditHook = auditHook) - - val headers = service.createNewAuthToken(user.getOgiriUserId(), "revoke-client") - - val request = - MockHttpServletRequest().apply { - addHeader("access-token", headers.accessToken) - addHeader("client", headers.client) - addHeader("uid", headers.uid) - addHeader("expiry", headers.expiry) - } - val response = MockHttpServletResponse() - - service.revokeClient(user.getOgiriUserId(), request, response) - - assertEquals(1, calls.size) - assertEquals("revoked:1:revoke-client", calls[0]) - } - - @Test - fun `issueSubTokens calls onSubTokenCreated for new sub-tokens`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onSubTokenCreated( - userId: Long, - parentClient: String, - subTokenName: String, - ) { - calls.add("subCreated:$userId:$parentClient:$subTokenName") - } - } - val registry = - DefaultOgiriSubTokenRegistry( - listOf( - object : OgiriSubTokenRegistration { - override val name = "chat" - override val includeByDefault = true - - override fun clientIdFor(parentClientId: String) = "$parentClientId.chat" - - override fun expiry(parentExpiry: Instant) = parentExpiry - })) - val service = createService(auditHook = auditHook, registry = registry) - - service.createNewAuthToken(user.getOgiriUserId(), "parent-client") - - assertTrue(calls.any { it == "subCreated:1:parent-client:chat" }) - } - - @Test - fun `revokeSubToken calls onSubTokenRevoked`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onSubTokenRevoked(userId: Long, subTokenName: String) { - calls.add("subRevoked:$userId:$subTokenName") - } - } - val registry = - DefaultOgiriSubTokenRegistry( - listOf( - object : OgiriSubTokenRegistration { - override val name = "device" - override val includeByDefault = true - - override fun clientIdFor(parentClientId: String) = "$parentClientId.device" - - override fun expiry(parentExpiry: Instant) = parentExpiry - })) - val service = createService(auditHook = auditHook, registry = registry) - service.createNewAuthToken(user.getOgiriUserId(), "parent-client") - - service.revokeSubToken(user.getOgiriUserId(), "device") - - assertEquals(1, calls.size) - assertEquals("subRevoked:1:device", calls[0]) - } - - @Test - fun `revokeSubToken does not call onSubTokenRevoked when no tokens found`() { - val calls = mutableListOf() - val auditHook = - object : OgiriAuditHook { - override fun onSubTokenRevoked(userId: Long, subTokenName: String) { - calls.add("subRevoked:$userId:$subTokenName") - } - } - val service = createService(auditHook = auditHook) - - service.revokeSubToken(user.getOgiriUserId(), "nonexistent") - - assertTrue(calls.isEmpty()) - } - } - - @Nested - inner class RateLimitHookInvocationTests { - @Test - fun `verifyUser calls beforeLogin`() { - val calls = mutableListOf() - val rateLimitHook = - object : OgiriRateLimitHook { - override fun beforeLogin(request: HttpServletRequest, identifier: String) { - calls.add("beforeLogin:$identifier") - } - } - val service = createService(rateLimitHook = rateLimitHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - service.verifyUser(request, response, "testuser@example.com", "password") - - assertEquals(1, calls.size) - assertEquals("beforeLogin:testuser@example.com", calls[0]) - } - - @Test - fun `rate limit hook throwing SecurityServiceException prevents login`() { - val rateLimitHook = - object : OgiriRateLimitHook { - override fun beforeLogin(request: HttpServletRequest, identifier: String) { - throw SecurityServiceException("error.auth.rate_limited") - } - } - val service = createService(rateLimitHook = rateLimitHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - val exception = - assertThrows(SecurityServiceException::class.java) { - service.verifyUser(request, response, "testuser@example.com", "password") - } - assertEquals("error.auth.rate_limited", exception.message) - } - - @Test - fun `createNewAuthToken with request calls beforeTokenCreation`() { - val calls = mutableListOf() - val rateLimitHook = - object : OgiriRateLimitHook { - override fun beforeTokenCreation(request: HttpServletRequest, userId: Long) { - calls.add("beforeTokenCreation:$userId:${request.remoteAddr}") - } - } - val service = createService(rateLimitHook = rateLimitHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-hook", request) - - assertTrue(headers.accessToken != null) - assertEquals(1, calls.size) - assertEquals("beforeTokenCreation:1:127.0.0.1", calls[0]) - } - - @Test - fun `verifyUser login flow triggers beforeTokenCreation`() { - val calls = mutableListOf() - val rateLimitHook = - object : OgiriRateLimitHook { - override fun beforeLogin(request: HttpServletRequest, identifier: String) { - calls.add("beforeLogin:$identifier") - } - - override fun beforeTokenCreation(request: HttpServletRequest, userId: Long) { - calls.add("beforeTokenCreation:$userId") - } - } - val service = createService(rateLimitHook = rateLimitHook) - - val request = MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - service.verifyUser(request, response, "testuser@example.com", "password") - - assertEquals(2, calls.size) - assertEquals("beforeLogin:testuser@example.com", calls[0]) - assertEquals("beforeTokenCreation:1", calls[1]) - } - - @Test - fun `renewSubToken(request) calls beforeSubTokenRenewal`() { - val calls = mutableListOf() - val rateLimitHook = - object : OgiriRateLimitHook { - override fun beforeSubTokenRenewal(request: HttpServletRequest, userId: Long) { - calls.add("beforeSubTokenRenewal:$userId:${request.remoteAddr}") - } - } - val registry = - DefaultOgiriSubTokenRegistry( - listOf( - object : OgiriSubTokenRegistration { - override val name = "chat" - override val includeByDefault = true - - override fun clientIdFor(parentClientId: String) = "$parentClientId.chat" - - override fun expiry(parentExpiry: Instant) = parentExpiry - })) - val service = createService(rateLimitHook = rateLimitHook, registry = registry) - - val headers = service.createNewAuthToken(user.getOgiriUserId(), "web") - - val renewRequest = - MockHttpServletRequest().apply { - remoteAddr = "10.0.0.1" - addHeader("access-token", headers.accessToken) - addHeader("client", headers.client) - addHeader("uid", headers.uid) - addHeader("expiry", headers.expiry) - } - val renewResponse = MockHttpServletResponse() - - service.renewSubToken(user.getOgiriUserId(), renewRequest, renewResponse, "chat") - - assertEquals(1, calls.size) - assertEquals("beforeSubTokenRenewal:1:10.0.0.1", calls[0]) - } - - @Test - fun `beforeSubTokenRenewal throwing SecurityServiceException prevents sub-token renewal`() { - val rateLimitHook = - object : OgiriRateLimitHook { - override fun beforeSubTokenRenewal(request: HttpServletRequest, userId: Long) { - throw SecurityServiceException("error.auth.rate_limited") - } - } - val registry = - DefaultOgiriSubTokenRegistry( - listOf( - object : OgiriSubTokenRegistration { - override val name = "chat" - override val includeByDefault = true - - override fun clientIdFor(parentClientId: String) = "$parentClientId.chat" - - override fun expiry(parentExpiry: Instant) = parentExpiry - })) - val service = createService(rateLimitHook = rateLimitHook, registry = registry) - val headers = service.createNewAuthToken(user.getOgiriUserId(), "web") - - val renewRequest = - MockHttpServletRequest().apply { - addHeader("access-token", headers.accessToken) - addHeader("client", headers.client) - addHeader("uid", headers.uid) - addHeader("expiry", headers.expiry) - } - val renewResponse = MockHttpServletResponse() - - val exception = - assertThrows(SecurityServiceException::class.java) { - service.renewSubToken(user.getOgiriUserId(), renewRequest, renewResponse, "chat") - } - assertEquals("error.auth.rate_limited", exception.message) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceOpenMethodsTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceOpenMethodsTest.kt deleted file mode 100644 index 5eadbbd..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceOpenMethodsTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import java.lang.reflect.Modifier -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Test -import org.springframework.transaction.annotation.Transactional - -class OgiriTokenServiceOpenMethodsTest { - - @Test - fun `all @Transactional methods must be non-final for CGLIB proxy interception`() { - // Group @Transactional methods by name. @JvmOverloads generates bridge methods - // (fewer params) that are marked final but delegate to the primary method. - // CGLIB only needs the primary (most-params) variant to be non-final. - val transactionalByName = - OgiriTokenService::class - .java - .declaredMethods - .filter { it.isAnnotationPresent(Transactional::class.java) } - .filter { !it.isSynthetic } - .groupBy { it.name } - - val allFinalMethods = - transactionalByName - .filter { (_, methods) -> methods.all { Modifier.isFinal(it.modifiers) } } - .keys - .toList() - - assertFalse(allFinalMethods.isNotEmpty()) { - "The following @Transactional methods have ALL variants final and will be silently " + - "skipped by CGLIB proxies: $allFinalMethods. " + - "Ensure @OgiriService (or another all-open trigger) is applied to the class." - } - } - - @Test - fun `OgiriTokenService class itself must be non-final`() { - assertFalse(Modifier.isFinal(OgiriTokenService::class.java.modifiers)) { - "OgiriTokenService must be non-final (open) for CGLIB proxying" - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceSetterInjectionTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceSetterInjectionTest.kt deleted file mode 100644 index 182109c..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceSetterInjectionTest.kt +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.NoOpOgiriAuditHook -import com.quantipixels.ogiri.security.spi.NoOpOgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriAuditHook -import com.quantipixels.ogiri.security.spi.OgiriRateLimitHook -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import java.time.Instant -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test -import org.springframework.security.crypto.password.PasswordEncoder - -/** - * Tests for the setter injection pattern on OgiriTokenService. - * - * Verifies that optional collaborators (auditHook, rateLimitHook, lookupCache) can be injected - * after construction via open setter methods, following the Spring Security AuthorizationFilter - * gold standard. - */ -@Tag("unit") -class OgiriTokenServiceSetterInjectionTest { - - private lateinit var repository: InMemoryTokenRepository - private val passwordEncoder: PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - private val identifierPolicy = - object : IdentifierPolicy { - private val counter = AtomicLong(0) - - override fun generate(): String = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() - } - - private val user = TestFixtures.testUser(userId = 1L, username = "testuser") - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = user.takeIf { "testuser@example.com" == email } - - override fun findByUsername(username: String) = user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - } - - private fun defaultProperties() = - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - } - } - - /** Creates a minimal 6-arg service with no optional collaborators set. */ - private fun createService(): OgiriTokenService = - object : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - DefaultOgiriSubTokenRegistry(emptyList()), - defaultProperties(), - ) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - - @BeforeEach - fun setup() { - repository = InMemoryTokenRepository() - } - - // --------------------------------------------------------------------------- - // Slice 1: No-op singleton objects are public and usable - // --------------------------------------------------------------------------- - - @Nested - inner class NoOpSingletonTests { - - @Test - fun `NoOpOgiriAuditHook is a singleton object that implements OgiriAuditHook`() { - // Verify it compiles as OgiriAuditHook and calling methods does nothing - val hook: OgiriAuditHook = NoOpOgiriAuditHook - hook.onLoginSuccess(1L, "client", "127.0.0.1") - hook.onLoginFailure("user", "reason", null) - hook.onTokenRotated(1L, "client") - hook.onTokenRevoked(1L, "client") - hook.onSubTokenCreated(1L, "parent", "sub") - // If we reach here without exception, the no-op object works correctly - assertTrue(true) - } - - @Test - fun `NoOpOgiriRateLimitHook is a singleton object that implements OgiriRateLimitHook`() { - // Verify it compiles as OgiriRateLimitHook and calling methods does nothing - val hook: OgiriRateLimitHook = NoOpOgiriRateLimitHook - // Rate limit hooks need HttpServletRequest — just verify the object identity is stable - // and the object reference is correct type - assertNotNull(hook) - assertTrue(hook is OgiriRateLimitHook) - } - - @Test - fun `NoOpOgiriAuditHook is a stable singleton reference`() { - val ref1: OgiriAuditHook = NoOpOgiriAuditHook - val ref2: OgiriAuditHook = NoOpOgiriAuditHook - assertTrue(ref1 === ref2, "NoOpOgiriAuditHook must be a singleton (same reference)") - } - - @Test - fun `NoOpOgiriRateLimitHook is a stable singleton reference`() { - val ref1: OgiriRateLimitHook = NoOpOgiriRateLimitHook - val ref2: OgiriRateLimitHook = NoOpOgiriRateLimitHook - assertTrue(ref1 === ref2, "NoOpOgiriRateLimitHook must be a singleton (same reference)") - } - } - - // --------------------------------------------------------------------------- - // Slice 2: 6-arg constructor works — service functional without setters called - // --------------------------------------------------------------------------- - - @Nested - inner class SixArgConstructorTests { - - @Test - fun `service constructed with 6 args is functional`() { - val service = createService() - val headers = service.createNewAuthToken(user.getOgiriUserId(), "client-a") - assertNotNull(headers.accessToken) - } - - @Test - fun `service without audit hook does not throw on login`() { - val service = createService() - val headers = service.createNewAuthToken(user.getOgiriUserId(), "no-hook-client") - assertNotNull(headers.accessToken) - } - } - - // --------------------------------------------------------------------------- - // Slice 3: setAuditHook — post-construction audit hook is invoked - // --------------------------------------------------------------------------- - - @Nested - inner class SetAuditHookTests { - - @Test - fun `setAuditHook wires audit hook that fires on token rotation`() { - val service = createService() - val calls = mutableListOf() - service.setAuditHook( - object : OgiriAuditHook { - override fun onTokenRotated(userId: Long, client: String) { - calls.add("rotated:$userId:$client") - } - }) - - val expiry = Instant.now().plusSeconds(3600) - service.createOrUpdateToken(user, "setter-client", expiry) - assertTrue(calls.isEmpty(), "First creation is not a rotation") - service.createOrUpdateToken(user, "setter-client", expiry) - assertEquals(1, calls.size) - assertEquals("rotated:1:setter-client", calls[0]) - } - - @Test - fun `setAuditHook replaces previously set hook`() { - val service = createService() - val firstCalls = mutableListOf() - val secondCalls = mutableListOf() - - service.setAuditHook( - object : OgiriAuditHook { - override fun onTokenRotated(userId: Long, client: String) { - firstCalls.add("rotated") - } - }) - - service.setAuditHook( - object : OgiriAuditHook { - override fun onTokenRotated(userId: Long, client: String) { - secondCalls.add("rotated") - } - }) - - val expiry = Instant.now().plusSeconds(3600) - service.createOrUpdateToken(user, "replace-client", expiry) - service.createOrUpdateToken(user, "replace-client", expiry) - - assertTrue(firstCalls.isEmpty(), "First hook must be replaced and not called") - assertEquals(1, secondCalls.size, "Second hook must be called") - } - } - - // --------------------------------------------------------------------------- - // Slice 4: setRateLimitHook — post-construction rate limit hook is invoked - // --------------------------------------------------------------------------- - - @Nested - inner class SetRateLimitHookTests { - - @Test - fun `setRateLimitHook wires rate limit hook that fires before token creation`() { - val service = createService() - val calls = mutableListOf() - service.setRateLimitHook( - object : OgiriRateLimitHook { - override fun beforeTokenCreation( - request: jakarta.servlet.http.HttpServletRequest, - userId: Long, - ) { - calls.add("beforeTokenCreation:$userId") - } - }) - - val request = - org.springframework.mock.web.MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" } - service.createNewAuthToken(user.getOgiriUserId(), "rate-client", request) - - assertEquals(1, calls.size) - assertEquals("beforeTokenCreation:1", calls[0]) - } - } - - // --------------------------------------------------------------------------- - // Slice 5: setLookupCache — post-construction cache is consulted - // --------------------------------------------------------------------------- - - @Nested - inner class SetLookupCacheTests { - - private inner class RecordingCache : OgiriTokenLookupCache { - val getCalls = mutableListOf() - val putCalls = mutableListOf() - val store = mutableMapOf() - - private fun key(userId: Long, client: String) = "$userId:$client" - - override fun get(userId: Long, client: String): TestToken? { - getCalls.add(key(userId, client)) - return store[key(userId, client)] - } - - override fun put(userId: Long, client: String, token: TestToken) { - putCalls.add(key(userId, client)) - store[key(userId, client)] = token - } - - override fun evict(userId: Long, client: String) { - store.remove(key(userId, client)) - } - - override fun evictAll(userId: Long) { - store.keys.removeIf { it.startsWith("$userId:") } - } - } - - @Test - fun `setLookupCache wires cache that is consulted on token lookup`() { - val service = createService() - val cache = RecordingCache() - service.setLookupCache(cache) - - val headers = service.createNewAuthToken(user.getOgiriUserId(), "cache-client") - val client = headers.client!! - val token = headers.accessToken!! - - cache.getCalls.clear() - cache.putCalls.clear() - - service.validToken(token, user, client) - - assertTrue( - cache.getCalls.isNotEmpty(), "Cache.get must be called after setLookupCache was used") - } - - @Test - fun `service without setLookupCache falls through to repository`() { - val service = createService() - // no setLookupCache called — should still work - val headers = service.createNewAuthToken(user.getOgiriUserId(), "no-cache-client") - assertNotNull(headers.accessToken) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceTimingTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceTimingTest.kt deleted file mode 100644 index d424c68..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenServiceTimingTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.core.SecurityServiceException -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import java.time.Instant -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder - -/** - * Regression guard for timing normalisation in verifyUser. - * - * DUMMY_HASH must be a well-formed BCrypt hash so that BCryptPasswordEncoder.matches() performs the - * full key-derivation rounds on the "user not found" path, preventing user enumeration via - * response-time diff. - */ -class OgiriTokenServiceTimingTest { - - @Test - fun `verifyUser with unknown user throws SecurityServiceException not IllegalArgumentException`() { - val service = buildService() - - assertThrows(SecurityServiceException::class.java) { - service.verifyUser( - MockHttpServletRequest().apply { remoteAddr = "127.0.0.1" }, - MockHttpServletResponse(), - "unknown@example.com", - "any-password", - ) - } - } - - private fun buildService(): OgiriTokenService { - val user = TestFixtures.testUser(userId = 1L, username = "testuser") - val counter = AtomicLong(0) - val props = - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - } - } - return object : - OgiriTokenService( - InMemoryTokenRepository(), - BCryptPasswordEncoder(), - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = user.takeIf { "known@example.com" == email } - - override fun findByUsername(username: String) = - user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - }, - object : IdentifierPolicy { - override fun generate() = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?) = !value.isNullOrBlank() - }, - DefaultOgiriSubTokenRegistry(emptyList()), - props, - ) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ) = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenTypeTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenTypeTest.kt deleted file mode 100644 index 0568e05..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/OgiriTokenTypeTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test - -class OgiriTokenTypeTest { - - @Test - fun `ofOrDefault should return APP for null input`() { - assertEquals(OgiriTokenType.APP, OgiriTokenType.ofOrDefault(null)) - } - - @Test - fun `ofOrDefault should return correct type for valid labels`() { - assertEquals(OgiriTokenType.APP, OgiriTokenType.ofOrDefault("app")) - assertEquals(OgiriTokenType.SUB, OgiriTokenType.ofOrDefault("sub")) - } - - @Test - fun `ofOrDefault should throw for invalid non-null labels`() { - assertThrows(IllegalArgumentException::class.java) { OgiriTokenType.ofOrDefault("invalid") } - assertThrows(IllegalArgumentException::class.java) { OgiriTokenType.ofOrDefault("") } - } - - @Test - fun `of should return correct type for valid labels`() { - assertEquals(OgiriTokenType.APP, OgiriTokenType.of("app")) - assertEquals(OgiriTokenType.SUB, OgiriTokenType.of("sub")) - } - - @Test - fun `of should throw for invalid labels`() { - assertThrows(IllegalArgumentException::class.java) { OgiriTokenType.of("invalid") } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenCleanupTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenCleanupTest.kt deleted file mode 100644 index ce59c3b..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenCleanupTest.kt +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import java.time.Instant -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.security.crypto.password.PasswordEncoder - -class TokenCleanupTest { - - private lateinit var repository: InMemoryTokenRepository - private lateinit var tokenService: OgiriTokenService - private val passwordEncoder: PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - private val identifierPolicy = - object : IdentifierPolicy { - private val counter = AtomicLong(0) - - override fun generate(): String = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() - } - - private val user = TestFixtures.testUser(userId = 1L, username = "user") - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = null - - override fun findByUsername(username: String) = user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - } - - private fun defaultProperties() = - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - } - } - - private inner class TestTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ) : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.label, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - } - - @BeforeEach - fun setUp() { - repository = InMemoryTokenRepository() - val props = defaultProperties() - val subTokenRegistry = DefaultOgiriSubTokenRegistry() - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - props, - ) - } - - @Nested - inner class DeleteByExpiryAtBeforeTests { - - @Test - fun `deletes tokens with expiry before cutoff`() { - val now = Instant.now() - val expiredToken1 = - TestToken.create(userId = 1L, client = "c1", expiryAt = now.minusSeconds(100)) - val expiredToken2 = - TestToken.create(userId = 1L, client = "c2", expiryAt = now.minusSeconds(50)) - val validToken = TestToken.create(userId = 1L, client = "c3", expiryAt = now.plusSeconds(100)) - - repository.save(expiredToken1) - repository.save(expiredToken2) - repository.save(validToken) - - val deletedCount = repository.deleteByExpiryAtBefore(now) - - assertEquals(2, deletedCount) - assertEquals(1, repository.getCount()) - assertEquals("c3", repository.getAllTokens().first().client) - } - - @Test - fun `returns zero when no tokens expired`() { - val now = Instant.now() - val validToken1 = - TestToken.create(userId = 1L, client = "c1", expiryAt = now.plusSeconds(100)) - val validToken2 = - TestToken.create(userId = 1L, client = "c2", expiryAt = now.plusSeconds(200)) - - repository.save(validToken1) - repository.save(validToken2) - - val deletedCount = repository.deleteByExpiryAtBefore(now) - - assertEquals(0, deletedCount) - assertEquals(2, repository.getCount()) - } - - @Test - fun `does not delete tokens with expiry equal to cutoff`() { - val cutoff = Instant.now() - val tokenAtCutoff = TestToken.create(userId = 1L, client = "c1", expiryAt = cutoff) - val tokenBeforeCutoff = - TestToken.create(userId = 1L, client = "c2", expiryAt = cutoff.minusSeconds(1)) - - repository.save(tokenAtCutoff) - repository.save(tokenBeforeCutoff) - - val deletedCount = repository.deleteByExpiryAtBefore(cutoff) - - assertEquals(1, deletedCount) - assertEquals(1, repository.getCount()) - assertEquals("c1", repository.getAllTokens().first().client) - } - } - - @Nested - inner class CleanupExpiredTokensTests { - - @Test - fun `cleanupExpiredTokens returns count of deleted tokens`() { - val now = Instant.now() - val expired1 = TestToken.create(userId = 1L, client = "c1", expiryAt = now.minusSeconds(100)) - val expired2 = TestToken.create(userId = 1L, client = "c2", expiryAt = now.minusSeconds(50)) - val expired3 = TestToken.create(userId = 2L, client = "c3", expiryAt = now.minusSeconds(10)) - - repository.save(expired1) - repository.save(expired2) - repository.save(expired3) - - val deletedCount = tokenService.cleanupExpiredTokens(now) - - assertEquals(3, deletedCount) - assertEquals(0, repository.getCount()) - } - - @Test - fun `cleanupExpiredTokens with no expired tokens returns zero`() { - val now = Instant.now() - val validToken = - TestToken.create(userId = 1L, client = "c1", expiryAt = now.plusSeconds(3600)) - - repository.save(validToken) - - val deletedCount = tokenService.cleanupExpiredTokens(now) - - assertEquals(0, deletedCount) - assertEquals(1, repository.getCount()) - } - } - - @Nested - inner class TokenDeletionTests { - - @Test - fun `deleteToken removes single token by userId and client`() { - val token = TestToken.create(userId = 1L, client = "test-client") - repository.save(token) - - tokenService.deleteToken(1L, "test-client") - - assertEquals(0, repository.getCount()) - } - - @Test - fun `deleteToken with collection removes multiple tokens`() { - val token1 = TestToken.create(userId = 1L, client = "c1") - val token2 = TestToken.create(userId = 1L, client = "c2") - val token3 = TestToken.create(userId = 1L, client = "c3") - repository.save(token1) - repository.save(token2) - repository.save(token3) - - tokenService.deleteToken(1L, listOf("c1", "c3")) - - assertEquals(1, repository.getCount()) - assertEquals("c2", repository.getAllTokens().first().client) - } - - @Test - fun `deleteAllForUser removes all tokens for user`() { - val user1Token1 = TestToken.create(userId = 1L, client = "c1") - val user1Token2 = TestToken.create(userId = 1L, client = "c2") - val user2Token = TestToken.create(userId = 2L, client = "c1") - repository.save(user1Token1) - repository.save(user1Token2) - repository.save(user2Token) - - tokenService.deleteAllForUser(1L) - - assertEquals(1, repository.getCount()) - assertEquals(2L, repository.getAllTokens().first().userId) - } - } - - @Nested - inner class GetTokenTests { - - @Test - fun `getAllByUserId returns all tokens for user`() { - val user1Token1 = TestToken.create(userId = 1L, client = "c1") - val user1Token2 = TestToken.create(userId = 1L, client = "c2") - val user2Token = TestToken.create(userId = 2L, client = "c1") - repository.save(user1Token1) - repository.save(user1Token2) - repository.save(user2Token) - - val tokens = tokenService.getAllByUserId(1L) - - assertEquals(2, tokens.size) - } - - @Test - fun `getByUserIdAndClient returns specific token`() { - val token = TestToken.create(userId = 1L, client = "specific-client") - repository.save(token) - - val found = tokenService.getByUserIdAndClient(1L, "specific-client") - - assertEquals("specific-client", found?.client) - } - - @Test - fun `getByUserIdAndClient returns null when not found`() { - val found = tokenService.getByUserIdAndClient(1L, "nonexistent") - - assertEquals(null, found) - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenServiceSubTokenTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenServiceSubTokenTest.kt deleted file mode 100644 index f713660..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TokenServiceSubTokenTest.kt +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.AuthHeader -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import java.time.Instant -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.crypto.password.PasswordEncoder - -class TokenServiceSubTokenTest { - private lateinit var repository: InMemoryTokenRepository - private lateinit var tokenService: OgiriTokenService - private val passwordEncoder: PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - private val identifierPolicy = - object : IdentifierPolicy { - private val counter = AtomicLong(0) - - override fun generate(): String = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() - } - - private val user = TestFixtures.testUser(userId = 1L, username = "user") - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = null - - override fun findByUsername(username: String) = user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - } - - private fun defaultAuthProperties() = - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - } - } - - // Custom TokenService that implements tokenFactory for TestToken - private inner class TestTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ) : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - - @BeforeEach - fun setup() { - repository = InMemoryTokenRepository() - } - - @Test - fun `default sub token is issued and returned in headers`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers: AuthHeader = tokenService.createNewAuthToken(user.getOgiriUserId(), "clientA") - - val chatToken = - repository.findByUserIdAndClient(user.getOgiriUserId(), "clientA.chat").orElse(null) - assertNotNull(chatToken) - assertEquals(OgiriTokenType.SUB, OgiriTokenType.of(chatToken!!.tokenType)) - assertEquals("chat", chatToken.tokenSubtype) - assertNotNull(headers.subTokens?.get("chat")) - } - - @Test - fun `opt-in sub token is only created when requested`() { - val registry = - DefaultOgiriSubTokenRegistry( - listOf(chatRegistration(), deviceRegistration(includeByDefault = false))) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), "web") - assertFalse(repository.findByUserIdAndClient(user.getOgiriUserId(), "web.device").isPresent) - - val req = - MockHttpServletRequest().apply { - addHeader("access-token", headers.accessToken) - addHeader("client", headers.client) - addHeader("uid", headers.uid) - addHeader("expiry", headers.expiry) - } - val res = MockHttpServletResponse() - - tokenService.renewSubToken(user.getOgiriUserId(), req, res, "device") - - assertTrue(repository.findByUserIdAndClient(user.getOgiriUserId(), "web.device").isPresent) - assertNotNull(res.getHeader("chat")) - } - - @Test - fun `validateSubToken accepts bearer payload`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - tokenService.createNewAuthToken(user.getOgiriUserId(), "clientZ") - val chat = - repository.findByUserIdAndClient(user.getOgiriUserId(), "clientZ.chat").orElse(null)!! - val bearerPayload = - mapOf( - "client" to requireNotNull(chat.client), - "token" to requireNotNull(chat.plainToken), - "expiry" to chat.expiryAt.toString(), - ) - val bearerJson = - com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(bearerPayload) - val bearer = - "Bearer " + - java.util.Base64.getEncoder().encodeToString(bearerJson.toByteArray(Charsets.UTF_8)) - - val ok = tokenService.validateSubToken(user.username, "chat", bearer) - assertEquals(true, ok) - val okRaw = tokenService.validateSubToken(user.username, "chat", chat.plainToken!!) - assertEquals(true, okRaw) - } - - @Test - fun `primary token is created with generated client ID`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), null) - - assertNotNull(headers.client) - val appToken = - repository.findByUserIdAndClient(user.getOgiriUserId(), headers.client!!).orElse(null) - assertNotNull(appToken) - assertEquals(OgiriTokenType.APP, OgiriTokenType.of(appToken!!.tokenType)) - } - - @Test - fun `primary token creation stores plain token temporarily`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), "primary") - - val appToken = repository.findByUserIdAndClient(user.getOgiriUserId(), "primary").orElse(null) - assertNotNull(appToken) - assertNotNull(appToken!!.plainToken) - } - - @Test - fun `primary token rotation tracks previous token`() { - val registry = DefaultOgiriSubTokenRegistry(listOf()) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers1 = tokenService.createNewAuthToken(user.getOgiriUserId(), "rotation-test") - val token1 = - repository.findByUserIdAndClient(user.getOgiriUserId(), "rotation-test").orElse(null)!! - val originalToken = token1.plainToken - - val headers2 = tokenService.createNewAuthToken(user.getOgiriUserId(), "rotation-test") - val token2 = - repository.findByUserIdAndClient(user.getOgiriUserId(), "rotation-test").orElse(null)!! - - assertEquals(originalToken, token2.lastToken) - } - - @Test - fun `old tokens are cleaned when max clients exceeded`() { - val registry = DefaultOgiriSubTokenRegistry(listOf()) - val properties = defaultAuthProperties().apply { auth.maxClients = 2 } - tokenService = - TestTokenService( - repository, passwordEncoder, userDirectory, identifierPolicy, registry, properties) - - // Create 3 tokens for the same user, with small delays to ensure different timestamps - tokenService.createNewAuthToken(user.getOgiriUserId(), "client-1") - repository.incrementClock() // deterministic clock bump instead of wall-clock sleep - tokenService.createNewAuthToken(user.getOgiriUserId(), "client-2") - repository.incrementClock() - tokenService.createNewAuthToken(user.getOgiriUserId(), "client-3") - // Verify only 2 tokens exist (the most recent ones) - val tokensAfter = repository.findByUserIdOrderByUpdatedAtDesc(user.getOgiriUserId()) - assertEquals(2, tokensAfter.size) - assertFalse(repository.findByUserIdAndClient(user.getOgiriUserId(), "client-1").isPresent) - assertTrue(repository.findByUserIdAndClient(user.getOgiriUserId(), "client-2").isPresent) - assertTrue(repository.findByUserIdAndClient(user.getOgiriUserId(), "client-3").isPresent) - } - - @Test - fun `sub token expiry respects parent token expiry`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), "parent") - val appToken = repository.findByUserIdAndClient(user.getOgiriUserId(), "parent").orElse(null)!! - val chatToken = - repository.findByUserIdAndClient(user.getOgiriUserId(), "parent.chat").orElse(null)!! - - assertEquals(appToken.expiryAt, chatToken.expiryAt) - } - - @Test - fun `sub token with custom expiry respects registration`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(shortLivedChatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), "custom-expiry") - val appToken = - repository.findByUserIdAndClient(user.getOgiriUserId(), "custom-expiry").orElse(null)!! - val chatToken = - repository.findByUserIdAndClient(user.getOgiriUserId(), "custom-expiry.chat").orElse(null)!! - - // Chat should expire earlier than app token - assertTrue(chatToken.expiryAt.isBefore(appToken.expiryAt)) - } - - @Test - fun `validateSubToken rejects expired sub token`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), "expired-test") - val chat = - repository.findByUserIdAndClient(user.getOgiriUserId(), "expired-test.chat").orElse(null)!! - - // Manually expire the token - chat.expiryAt = java.time.Instant.now().minusSeconds(1) - repository.save(chat) - - val isValid = tokenService.validateSubToken(user.username, "chat", chat.plainToken!!) - assertEquals(false, isValid) - } - - @Test - fun `renewSubToken helper returns single sub-token header`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(deviceRegistration(includeByDefault = true))) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - val headers = tokenService.createNewAuthToken(user.getOgiriUserId(), "renewal") - val renewed = tokenService.renewSubToken(user.getOgiriUserId(), headers.client!!, "device") - - assertNotNull(renewed?.subTokens?.get("device")) - assertEquals("renewal.device", renewed?.subTokens?.get("device")?.client) - } - - @Test - fun `getSubToken returns stored base token`() { - val registry = DefaultOgiriSubTokenRegistry(listOf(chatRegistration())) - tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - registry, - defaultAuthProperties()) - - tokenService.createNewAuthToken(user.getOgiriUserId(), "base-client") - val stored = tokenService.getSubToken(user.getOgiriUserId(), "chat") - - assertNotNull(stored) - assertEquals("base-client.chat", stored!!.client) - } - - private fun chatRegistration(): OgiriSubTokenRegistration = - object : OgiriSubTokenRegistration { - override val name: String = "chat" - override val includeByDefault: Boolean = true - - override fun clientIdFor(parentClientId: String): String = "$parentClientId.chat" - - override fun expiry(parentExpiry: Instant): Instant = parentExpiry - } - - private fun deviceRegistration(includeByDefault: Boolean) = - object : OgiriSubTokenRegistration { - override val name: String = "device" - override val includeByDefault: Boolean = includeByDefault - - override fun clientIdFor(parentClientId: String): String = "$parentClientId.device" - - override fun expiry(parentExpiry: Instant): Instant = parentExpiry - } - - private fun shortLivedChatRegistration(): OgiriSubTokenRegistration = - object : OgiriSubTokenRegistration { - override val name: String = "chat" - override val includeByDefault: Boolean = true - - override fun clientIdFor(parentClientId: String): String = "$parentClientId.chat" - - override fun expiry(parentExpiry: Instant): Instant = parentExpiry.minusSeconds(3600) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TryDecodeSubBearerTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TryDecodeSubBearerTest.kt deleted file mode 100644 index 92926eb..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/tokens/TryDecodeSubBearerTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.tokens - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import java.time.Instant -import java.util.Base64 -import java.util.concurrent.atomic.AtomicLong -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.security.crypto.password.PasswordEncoder - -class TryDecodeSubBearerTest { - private lateinit var tokenService: OgiriTokenService - - private val passwordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - - private val identifierPolicy = - object : IdentifierPolicy { - private val counter = AtomicLong(0) - - override fun generate(): String = "tok-${counter.incrementAndGet()}" - - override fun isValid(value: String?): Boolean = !value.isNullOrBlank() - } - - private val user = TestFixtures.testUser() - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = null - - override fun findByUsername(username: String) = user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - } - - @BeforeEach - fun setup() { - val repository = InMemoryTokenRepository() - tokenService = - object : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - DefaultOgiriSubTokenRegistry(emptyList()), - OgiriConfigurationProperties()) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - } - - private fun encode(json: String): String = - Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) - - @Test - fun `valid JSON with all fields returns SubTokenHeader`() { - val encoded = encode("""{"client":"c1","token":"t1","expiry":"2026-01-01T00:00:00Z"}""") - val result = tokenService.tryDecodeSubBearer(encoded) - assertNotNull(result) - assertEquals("c1", result!!.client) - assertEquals("t1", result.token) - assertEquals("2026-01-01T00:00:00Z", result.expiry) - } - - @Test - fun `valid JSON with missing fields returns SubTokenHeader with nulls`() { - val encoded = encode("""{"other":"value"}""") - val result = tokenService.tryDecodeSubBearer(encoded) - assertNotNull(result) - assertNull(result!!.client) - assertNull(result.token) - assertNull(result.expiry) - } - - @Test - fun `malformed Base64 returns null`() { - val result = tokenService.tryDecodeSubBearer("not-valid-base64!!!") - assertNull(result) - } - - @Test - fun `valid Base64 but invalid JSON returns null`() { - val encoded = encode("this is not json") - val result = tokenService.tryDecodeSubBearer(encoded) - assertNull(result) - } - - @Test - fun `valid Base64 JSON array (wrong shape) returns null`() { - val encoded = encode("""[1, 2, 3]""") - val result = tokenService.tryDecodeSubBearer(encoded) - assertNull(result) - } - - @Test - fun `empty string returns null`() { - val result = tokenService.tryDecodeSubBearer("") - assertNull(result) - } - - @Test - fun `valid JSON with numeric field values returns SubTokenHeader with nulls for those fields`() { - val encoded = encode("""{"client":123,"token":true,"expiry":null}""") - val result = tokenService.tryDecodeSubBearer(encoded) - assertNotNull(result) - assertNull(result!!.client) - assertNull(result.token) - assertNull(result.expiry) - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPointTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPointTest.kt deleted file mode 100644 index f5e174b..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriAuthenticationEntryPointTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.web - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.ACCESS_TOKEN -import com.quantipixels.ogiri.security.core.CLIENT -import com.quantipixels.ogiri.security.core.EXPIRY -import com.quantipixels.ogiri.security.core.UID -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.context.support.StaticMessageSource -import org.springframework.http.MediaType -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.core.AuthenticationException - -class OgiriAuthenticationEntryPointTest { - private val messageSource = StaticMessageSource() - private val mapper = jacksonObjectMapper() - - private fun createProperties(cookiesEnabled: Boolean = true): OgiriConfigurationProperties { - val properties = OgiriConfigurationProperties() - properties.cookies.enabled = cookiesEnabled - properties.cookies.path = "/" - return properties - } - - @Test - fun `sends 401 response with JSON error payload`() { - val properties = createProperties() - val entryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - val request = MockHttpServletRequest() - val response = MockHttpServletResponse() - val exception: AuthenticationException = BadCredentialsException("Invalid credentials") - - entryPoint.commence(request, response, exception) - - assertEquals(401, response.status) - assertEquals(MediaType.APPLICATION_JSON_VALUE, response.contentType) - val payload = mapper.readValue(response.contentAsString, Map::class.java) - assertEquals(401, payload["status"]) - assertNotNull(payload["message"]) - } - - @Test - fun `clears authentication cookies when cookies enabled on 401`() { - val properties = createProperties(cookiesEnabled = true) - val entryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - val request = MockHttpServletRequest() - val response = MockHttpServletResponse() - val exception: AuthenticationException = BadCredentialsException("Invalid credentials") - - entryPoint.commence(request, response, exception) - - val cookies = response.cookies - assertEquals(4, cookies.size, "Should clear all 4 auth cookies") - - val cookieNames = cookies.map { it.name }.toSet() - assertTrue(cookieNames.contains(ACCESS_TOKEN), "Should clear access-token cookie") - assertTrue(cookieNames.contains(CLIENT), "Should clear client cookie") - assertTrue(cookieNames.contains(UID), "Should clear uid cookie") - assertTrue(cookieNames.contains(EXPIRY), "Should clear expiry cookie") - - cookies.forEach { cookie -> - assertEquals(0, cookie.maxAge, "Cookie ${cookie.name} should have maxAge=0") - assertEquals("", cookie.value, "Cookie ${cookie.name} should have empty value") - assertEquals("/", cookie.path, "Cookie ${cookie.name} should have correct path") - } - } - - @Test - fun `does not clear cookies when cookies disabled on 401`() { - val properties = createProperties(cookiesEnabled = false) - val entryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - val request = MockHttpServletRequest() - val response = MockHttpServletResponse() - val exception: AuthenticationException = BadCredentialsException("Invalid credentials") - - entryPoint.commence(request, response, exception) - - assertEquals(401, response.status) - assertEquals(0, response.cookies.size, "Should not set any cookies when cookies disabled") - } - - @Test - fun `uses default message for BadCredentialsException when no custom message`() { - val properties = createProperties() - val entryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - val request = MockHttpServletRequest() - val response = MockHttpServletResponse() - val exception: AuthenticationException = BadCredentialsException("Invalid credentials") - - entryPoint.commence(request, response, exception) - - val payload = mapper.readValue(response.contentAsString, Map::class.java) - assertEquals("Invalid credentials", payload["message"]) - } - - @Test - fun `uses default message for generic AuthenticationException when no custom message`() { - val properties = createProperties() - val entryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - val request = MockHttpServletRequest() - val response = MockHttpServletResponse() - val exception: AuthenticationException = - object : AuthenticationException("Generic auth error") {} - - entryPoint.commence(request, response, exception) - - val payload = mapper.readValue(response.contentAsString, Map::class.java) - assertEquals("Authentication required", payload["message"]) - } - - @Test - fun `clears cookies with custom cookie path`() { - val properties = createProperties(cookiesEnabled = true) - properties.cookies.path = "/api" - val entryPoint = OgiriAuthenticationEntryPoint(messageSource, properties) - val request = MockHttpServletRequest() - val response = MockHttpServletResponse() - val exception: AuthenticationException = BadCredentialsException("Invalid credentials") - - entryPoint.commence(request, response, exception) - - response.cookies.forEach { cookie -> - assertEquals("/api", cookie.path, "Cookie ${cookie.name} should use custom path") - } - } -} diff --git a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilterTest.kt b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilterTest.kt deleted file mode 100644 index af7f385..0000000 --- a/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/web/OgiriTokenAuthenticationFilterTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.web - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.AuthHeader -import com.quantipixels.ogiri.security.core.DefaultIdentifierPolicy -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.helpers.AuthenticationBypassDecider -import com.quantipixels.ogiri.security.routes.OgiriRoute -import com.quantipixels.ogiri.security.routes.OgiriRouteCatalog -import com.quantipixels.ogiri.security.routes.OgiriRouteRegistry -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.testutil.InMemoryTokenRepository -import com.quantipixels.ogiri.security.testutil.TestFixtures -import com.quantipixels.ogiri.security.testutil.TestToken -import com.quantipixels.ogiri.security.tokens.DefaultOgiriSubTokenRegistry -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import java.time.Instant -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockFilterChain -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.security.web.AuthenticationEntryPoint - -class OgiriTokenAuthenticationFilterTest { - @Suppress("DEPRECATION") - private val passwordEncoder: PasswordEncoder = - object : PasswordEncoder { - override fun encode(rawPassword: CharSequence): String = rawPassword.toString() - - override fun matches(rawPassword: CharSequence, encodedPassword: String): Boolean = - rawPassword.toString() == encodedPassword - } - private val identifierPolicy = DefaultIdentifierPolicy() - - private val user = TestFixtures.testUser(userId = 1L, username = "user") - private val userDirectory = - object : OgiriUserDirectory { - override fun loadUserByUsername(username: String) = user - - override fun findById(id: Long) = user.takeIf { it.getOgiriUserId() == id } - - override fun findByEmail(email: String) = null - - override fun findByUsername(username: String) = user.takeIf { it.username == username } - - override fun recordSuccessfulLogin(userId: Long) {} - } - - private fun defaultAuthProperties() = - OgiriConfigurationProperties().apply { - auth.apply { - maxClients = 24 - batchGraceSeconds = 5 - tokenLifespanDays = 14 - rotateStaleSeconds = 0 // Disable staleness-based rotation for tests - } - } - - // Custom TokenService that implements tokenFactory for TestToken - private inner class TestTokenService( - repository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, - ) : - OgiriTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties) { - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: com.quantipixels.ogiri.security.tokens.OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): TestToken = - TestToken( - userId = userId, - client = client, - token = hashedToken, - tokenType = tokenType.name, - expiryAt = expiry, - tokenSubtype = tokenSubtype, - ) - .apply { plainToken = plainTokenValue } - } - - @AfterEach - fun clearContext() { - SecurityContextHolder.clearContext() - } - - @Test - fun `filter bypasses when decider allows`() { - val bypassRoutes = - OgiriRouteCatalog( - listOf( - object : OgiriRouteRegistry { - override fun routes() = listOf(OgiriRoute.get("/public", useAuth = false)) - }, - ), - ) - val bypassDecider = AuthenticationBypassDecider(bypassRoutes) - val entryPoint = RecordingEntryPoint() - val filter = newFilter(InMemoryTokenRepository(), bypassDecider, entryPoint).filter - - val request = MockHttpServletRequest("GET", "/public") - val response = MockHttpServletResponse() - val chain = MockFilterChain() - - filter.doFilter(request, response, chain) - - assertNull(SecurityContextHolder.getContext().authentication) - // Entry point should never be called on bypass - assertNull(entryPoint.lastRequest) - } - - @Test - fun `filter authenticates within batch window without rotation`() { - val fixture = newFilter() - - // Issue a token using the same TokenService used by the filter - val clientId = identifierPolicy.generate() - val headers: AuthHeader = - fixture.tokenService.createNewAuthToken(user.getOgiriUserId(), clientId) - - val request = MockHttpServletRequest("GET", "/api/secure") - request.addHeader("access-token", headers.accessToken!!) - request.addHeader("client", headers.client!!) - request.addHeader("uid", headers.uid!!) - request.addHeader("expiry", headers.expiry!!) - request.addHeader("access-token-kind", "app") - val response = MockHttpServletResponse() - val chain = MockFilterChain() - - fixture.filter.doFilter(request, response, chain) - - val issuedToken = - fixture.repository.findByUserIdAndClient(user.getOgiriUserId(), clientId).orElse(null) - - // Authentication should be present - assertNotNull(SecurityContextHolder.getContext().authentication) - // Batch window requests should record activity on the token - assertNotNull(issuedToken?.lastUsedAt) - // Entry point should not be called - assertNull(fixture.entryPoint.lastRequest) - // Freshly issued tokens are treated as batch requests; no rotation headers are appended - assertNull(response.getHeader("access-token")) - } - - private data class FilterFixture( - val repository: OgiriTokenRepository, - val tokenService: OgiriTokenService, - val entryPoint: RecordingEntryPoint, - val filter: OgiriTokenAuthenticationFilter, - ) - - private fun newFilter( - repository: OgiriTokenRepository = InMemoryTokenRepository(), - bypassDecider: AuthenticationBypassDecider = - AuthenticationBypassDecider(OgiriRouteCatalog(emptyList())), - entryPoint: RecordingEntryPoint = RecordingEntryPoint(), - ): FilterFixture { - val properties = OgiriConfigurationProperties() - val tokenService = - TestTokenService( - repository, - passwordEncoder, - userDirectory, - identifierPolicy, - DefaultOgiriSubTokenRegistry(emptyList()), - defaultAuthProperties(), - ) - val filter = - OgiriTokenAuthenticationFilter( - userDirectory, - tokenService, - entryPoint, - bypassDecider, - identifierPolicy, - properties, - ) - return FilterFixture(repository, tokenService, entryPoint, filter) - } -} - -private class RecordingEntryPoint : AuthenticationEntryPoint { - var lastRequest: HttpServletRequest? = null - - override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: org.springframework.security.core.AuthenticationException, - ) { - lastRequest = request - } -} diff --git a/ogiri-core/src/test/kotlin/example/consumer/OgiriEndpointAutoConfigurationTest.kt b/ogiri-core/src/test/kotlin/example/consumer/OgiriEndpointAutoConfigurationTest.kt deleted file mode 100644 index 770c026..0000000 --- a/ogiri-core/src/test/kotlin/example/consumer/OgiriEndpointAutoConfigurationTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package example.consumer - -import com.quantipixels.ogiri.security.session.OgiriProblemHandler -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.test.InMemorySessionStore -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.annotation.Bean -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.userdetails.User -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.provisioning.InMemoryUserDetailsManager -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.post - -@SpringBootTest( - classes = [OgiriEndpointAutoConfigurationTest.TestApplication::class], - properties = - [ - "ogiri.session.enabled=true", - "ogiri.session.token-hash.current-key-id=test", - "ogiri.session.token-hash.keys.test=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "ogiri.session.endpoints.enabled=true", - "ogiri.session.endpoints.base-path=/api/session-auth", - "ogiri.session.public-paths[0]=/api/session-auth/sign-in", - ], -) -@AutoConfigureMockMvc -class OgiriEndpointAutoConfigurationTest { - @Autowired private lateinit var mockMvc: MockMvc - @Autowired private lateinit var problemHandler: OgiriProblemHandler - - @Test - fun `external consumer receives auto-configured problem responses on custom path`() { - assertNotNull(problemHandler) - - mockMvc - .post("/api/session-auth/sign-in") { - contentType = MediaType.APPLICATION_JSON - content = """{"username":"","password":""}""" - } - .andExpect { - status { isUnprocessableEntity() } - header { string(HttpHeaders.CACHE_CONTROL, "no-store") } - jsonPath("$.code") { value("invalid_request") } - } - - mockMvc - .post("/api/session-auth/sign-in") { - contentType = MediaType.APPLICATION_JSON - content = """{"username":"user-42","password":"password","clientId":"browser"}""" - } - .andExpect { - status { isCreated() } - header { exists(HttpHeaders.AUTHORIZATION) } - } - - mockMvc - .post("/auth/sign-in") { - contentType = MediaType.APPLICATION_JSON - content = """{"username":"user-42","password":"password"}""" - } - .andExpect { status { isUnauthorized() } } - } - - @SpringBootApplication - class TestApplication { - @Bean fun sessionStore(): SessionStore = InMemorySessionStore() - - @Bean - fun userDetailsService(): UserDetailsService = - InMemoryUserDetailsManager( - User.withUsername("user-42").password("{noop}password").roles("USER").build()) - - @Bean - fun authenticationManager(users: UserDetailsService): AuthenticationManager = - AuthenticationManager { request -> - val user = users.loadUserByUsername(request.name) - if (request.credentials != "password") throw BadCredentialsException("bad_credentials") - UsernamePasswordAuthenticationToken.authenticated(user, null, user.authorities) - } - } -} diff --git a/ogiri-core/src/testFixtures/kotlin/com/quantipixels/ogiri/security/OgiriStubToken.kt b/ogiri-core/src/testFixtures/kotlin/com/quantipixels/ogiri/security/OgiriStubToken.kt deleted file mode 100644 index 57b5231..0000000 --- a/ogiri-core/src/testFixtures/kotlin/com/quantipixels/ogiri/security/OgiriStubToken.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security - -import com.quantipixels.ogiri.security.tokens.OgiriToken -import java.time.Instant - -/** Minimal [OgiriToken] implementation for cache and service unit tests. */ -data class OgiriStubToken( - override var id: Long = 1L, - override var userId: Long = 1L, - override var client: String = "client", - override var token: String = "hash", - override var tokenType: String = "APP", - override var expiryAt: Instant = Instant.now().plusSeconds(3600), - override var createdAt: Instant = Instant.now(), - override var updatedAt: Instant = Instant.now(), - override var tokenUpdatedAt: Instant = Instant.now(), - override var tokenSubtype: String? = null, - override var lastToken: String? = null, - override var previousToken: String? = null, - override var lastUsedAt: Instant? = null, - override var plainToken: String? = null, -) : OgiriToken diff --git a/ogiri-jdbc/build.gradle.kts b/ogiri-jdbc/build.gradle.kts deleted file mode 100644 index b967dc9..0000000 --- a/ogiri-jdbc/build.gradle.kts +++ /dev/null @@ -1,148 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - kotlin("plugin.spring") - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - `maven-publish` - signing - jacoco -} - -group = "com.quantipixels.ogiri" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } - jvmToolchain(17) -} - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -dependencies { - api(project(":ogiri-core")) - api(project(":ogiri-session-core")) - api("org.springframework.boot:spring-boot-starter-jdbc") - - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testImplementation("com.h2database:h2") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required = true - csv.required = false - html.required = true - } -} - -jacoco { toolVersion = libs.versions.jacoco.get() } - -// Coverage baseline: 85%. Raise this as tests are added. -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { rule { limit { minimum = "0.85".toBigDecimal() } } } -} - -tasks.named("check") { dependsOn(tasks.jacocoTestCoverageVerification) } - -publishing { - publications { - create("mavenJava") { - artifactId = "ogiri-jdbc" - artifact(tasks.jar) - artifact(tasks.named("sourcesJar")) - artifact(tasks.named("javadocJar")) - - versionMapping { - usage("java-api") { fromResolutionOf("runtimeClasspath") } - usage("java-runtime") { fromResolutionResult() } - } - - pom { - name.set("ogiri-jdbc") - description.set("JDBC adapter module for Ogiri token security library.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - withXml { - val dependenciesNode = asNode().appendNode("dependencies") - - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", project.group) - appendNode("artifactId", "ogiri-core") - appendNode("version", project.version) - appendNode("scope", "compile") - } - - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", "org.springframework.boot") - appendNode("artifactId", "spring-boot-starter-jdbc") - appendNode("scope", "compile") - } - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val signingKey = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val signingPassword = - (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - - if (signingKey != null && signingPassword != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - val pub = publishing.publications.findByName("mavenJava") - if (pub != null) sign(pub) - } -} diff --git a/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriBaseTokenRow.kt b/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriBaseTokenRow.kt deleted file mode 100644 index 3844196..0000000 --- a/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriBaseTokenRow.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jdbc - -import com.quantipixels.ogiri.security.tokens.OgiriBaseToken -import com.quantipixels.ogiri.security.tokens.OgiriTokenType -import java.time.Instant - -/** - * Base JDBC token row with all required token fields as constructor parameters. - * - * Extend this class, add your custom fields, and implement [OgiriJdbcTokenRepository] to provide a - * [org.springframework.jdbc.core.RowMapper] that maps your table columns to the row class. - * - * Example: - * ```kotlin - * class MyTokenRow( - * id: Long = 0, - * userId: Long = 0, - * client: String = "", - * token: String = "", - * expiryAt: Instant = Instant.now(), - * val tenantId: String? = null, - * ) : OgiriBaseTokenRow(id, userId, client, token, expiryAt = expiryAt) - * ``` - */ -open class OgiriBaseTokenRow( - override var id: Long = 0, - override var userId: Long = 0, - override var client: String = "", - override var token: String = "", - override var tokenType: String = OgiriTokenType.APP.label, - override var expiryAt: Instant = Instant.now(), - override var createdAt: Instant = Instant.now(), - override var updatedAt: Instant = Instant.now(), - override var tokenUpdatedAt: Instant = Instant.now(), -) : OgiriBaseToken() diff --git a/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcAutoConfiguration.kt b/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcAutoConfiguration.kt deleted file mode 100644 index 480dc1c..0000000 --- a/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcAutoConfiguration.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jdbc - -import com.quantipixels.ogiri.security.config.OgiriSecurityAutoConfiguration -import org.springframework.boot.autoconfigure.AutoConfigureAfter -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass -import org.springframework.context.annotation.Configuration -import org.springframework.jdbc.core.simple.JdbcClient - -/** - * Auto-configuration for Ogiri JDBC support. - * - * Activated when [JdbcClient] is on the classpath (Spring 6 / Spring Boot 3+). Loaded after - * [OgiriSecurityAutoConfiguration]. - * - * To use, extend [OgiriJdbcTokenRepository] and provide a row class that extends - * [OgiriBaseTokenRow]: - * ```kotlin - * @Repository - * class MyTokenRepository(jdbcClient: JdbcClient) : OgiriJdbcTokenRepository(jdbcClient) { - * override fun tableName() = "tokens" - * override fun rowMapper() = RowMapper { rs, _ -> MyTokenRow(...) } - * } - * ``` - */ -@Configuration -@ConditionalOnClass(JdbcClient::class) -@AutoConfigureAfter(OgiriSecurityAutoConfiguration::class) -class OgiriJdbcAutoConfiguration diff --git a/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepository.kt b/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepository.kt deleted file mode 100644 index b969893..0000000 --- a/ogiri-jdbc/src/main/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepository.kt +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jdbc - -import com.quantipixels.ogiri.security.tokens.OgiriBaseToken -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import java.time.Instant -import java.util.Optional -import org.springframework.jdbc.core.RowMapper -import org.springframework.jdbc.core.simple.JdbcClient -import org.springframework.jdbc.support.GeneratedKeyHolder - -/** - * Abstract [JdbcClient]-based implementation of [OgiriTokenRepository]. - * - * Provides all standard query and mutation operations using ANSI SQL with named parameters. Column - * names are fixed (see [OgiriBaseTokenRow]). Insert vs. update is detected by `id == 0L`. - * - * ## Timestamp behaviour - * - * On **INSERT**, both `created_at` and `updated_at` are stamped from the token object, which the - * caller must initialise before calling [save]. On **UPDATE**, `updated_at` is overwritten with - * `Instant.now()` inside [save]; `created_at` is never updated. This mirrors JPA's - * `@CreationTimestamp` / `@PreUpdate` semantics. - * - * Subclasses must implement: - * - [tableName]: unqualified table name (e.g., `"tokens"`) - * - [rowMapper]: maps all token columns to a [OgiriBaseToken] subtype - * - * Example: - * ```kotlin - * @Repository - * class MyTokenRepository(client: JdbcClient) : OgiriJdbcTokenRepository(client) { - * override fun tableName() = "user_tokens" - * override fun rowMapper() = RowMapper { rs, _ -> - * MyTokenRow(id = rs.getLong("id"), userId = rs.getLong("user_id"), ...) - * } - * } - * ``` - */ -abstract class OgiriJdbcTokenRepository( - protected val jdbcClient: JdbcClient, -) : OgiriTokenRepository { - - /** - * The database table name used for all queries in this repository. Must not include a schema - * prefix unless your datasource targets a fixed schema. - */ - abstract fun tableName(): String - - /** - * Maps a [java.sql.ResultSet] row to an instance of [T]. Must read all columns defined on - * [OgiriBaseTokenRow] (`id`, `user_id`, `client`, `token_hash`, `token_type`, `token_subtype`, - * `expiry_at`, `previous_token_hash`, `last_token_hash`, `token_updated_at`, `last_used_at`, - * `created_at`, `updated_at`). - */ - abstract fun rowMapper(): RowMapper - - override fun save(token: S): S { - if (token.id == 0L) { - val keyHolder = GeneratedKeyHolder() - jdbcClient - .sql( - "INSERT INTO ${tableName()} (user_id, client, token_hash, token_type, token_subtype, expiry_at, previous_token_hash, last_token_hash, token_updated_at, last_used_at, created_at, updated_at) VALUES (:userId, :client, :token, :tokenType, :tokenSubtype, :expiryAt, :previousToken, :lastToken, :tokenUpdatedAt, :lastUsedAt, :createdAt, :updatedAt)") - .param("userId", token.userId) - .param("client", token.client) - .param("token", token.token) - .param("tokenType", token.tokenType) - .param("tokenSubtype", token.tokenSubtype) - .param("expiryAt", token.expiryAt) - .param("previousToken", token.previousToken) - .param("lastToken", token.lastToken) - .param("tokenUpdatedAt", token.tokenUpdatedAt) - .param("lastUsedAt", token.lastUsedAt) - .param("createdAt", token.createdAt) - .param("updatedAt", token.updatedAt) - .update(keyHolder) - val keys = - keyHolder.keys ?: error("No generated keys returned for INSERT into ${tableName()}") - token.id = - keys.entries - .firstOrNull { it.key.equals("id", ignoreCase = true) } - ?.let { (_, v) -> (v as Number).toLong() } - ?: error("Generated keys map for ${tableName()} contains no 'id' entry: $keys") - return token - } else { - val now = Instant.now() - token.updatedAt = now - jdbcClient - .sql( - "UPDATE ${tableName()} SET token_hash = :token, token_type = :tokenType, token_subtype = :tokenSubtype, expiry_at = :expiryAt, previous_token_hash = :previousToken, last_token_hash = :lastToken, token_updated_at = :tokenUpdatedAt, last_used_at = :lastUsedAt, updated_at = :updatedAt WHERE id = :id") - .param("token", token.token) - .param("tokenType", token.tokenType) - .param("tokenSubtype", token.tokenSubtype) - .param("expiryAt", token.expiryAt) - .param("previousToken", token.previousToken) - .param("lastToken", token.lastToken) - .param("tokenUpdatedAt", token.tokenUpdatedAt) - .param("lastUsedAt", token.lastUsedAt) - .param("updatedAt", token.updatedAt) - .param("id", token.id) - .update() - return token - } - } - - override fun findById(id: Long): Optional = - jdbcClient - .sql("SELECT * FROM ${tableName()} WHERE id = :id") - .param("id", id) - .query(rowMapper()) - .optional() - - override fun deleteById(id: Long) { - jdbcClient.sql("DELETE FROM ${tableName()} WHERE id = :id").param("id", id).update() - } - - override fun delete(token: T) = deleteById(token.id) - - override fun findByUserIdOrderByUpdatedAtDesc(userId: Long): List = - jdbcClient - .sql("SELECT * FROM ${tableName()} WHERE user_id = :userId ORDER BY updated_at DESC") - .param("userId", userId) - .query(rowMapper()) - .list() - - override fun findByUserIdAndClient(userId: Long, client: String): Optional = - jdbcClient - .sql("SELECT * FROM ${tableName()} WHERE user_id = :userId AND client = :client") - .param("userId", userId) - .param("client", client) - .query(rowMapper()) - .optional() - - override fun findByUserIdAndClientIn(userId: Long, clients: Collection): List { - if (clients.isEmpty()) return emptyList() - return jdbcClient - .sql("SELECT * FROM ${tableName()} WHERE user_id = :userId AND client IN (:clients)") - .param("userId", userId) - .param("clients", clients) - .query(rowMapper()) - .list() - } - - override fun findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc( - userId: Long, - tokenSubtype: String, - ): List = - jdbcClient - .sql( - "SELECT * FROM ${tableName()} WHERE user_id = :userId AND token_subtype = :tokenSubtype ORDER BY updated_at DESC") - .param("userId", userId) - .param("tokenSubtype", tokenSubtype) - .query(rowMapper()) - .list() - - override fun findByExpiryAtBefore(cutoff: Instant): List = - jdbcClient - .sql("SELECT * FROM ${tableName()} WHERE expiry_at < :cutoff") - .param("cutoff", cutoff) - .query(rowMapper()) - .list() - - override fun fetchTopExpiredBefore(cutoff: Instant, limit: Int): List = - jdbcClient - .sql( - "SELECT * FROM ${tableName()} WHERE expiry_at < :cutoff ORDER BY expiry_at LIMIT :limit") - .param("cutoff", cutoff) - .param("limit", limit) - .query(rowMapper()) - .list() - - override fun findByTokenType(tokenType: String): List = - jdbcClient - .sql("SELECT * FROM ${tableName()} WHERE token_type = :tokenType") - .param("tokenType", tokenType) - .query(rowMapper()) - .list() - - override fun deleteByUserIdAndClient(userId: Long, client: String) { - jdbcClient - .sql("DELETE FROM ${tableName()} WHERE user_id = :userId AND client = :client") - .param("userId", userId) - .param("client", client) - .update() - } - - override fun deleteByUserIdAndClientIn(userId: Long, clients: Collection) { - if (clients.isEmpty()) return - jdbcClient - .sql("DELETE FROM ${tableName()} WHERE user_id = :userId AND client IN (:clients)") - .param("userId", userId) - .param("clients", clients) - .update() - } - - override fun deleteByUserId(userId: Long) { - jdbcClient - .sql("DELETE FROM ${tableName()} WHERE user_id = :userId") - .param("userId", userId) - .update() - } - - override fun countByUserId(userId: Long): Long = - jdbcClient - .sql("SELECT COUNT(*) FROM ${tableName()} WHERE user_id = :userId") - .param("userId", userId) - .query(Long::class.java) - .single() - - override fun deleteByExpiryAtBefore(cutoff: Instant): Int = - jdbcClient - .sql("DELETE FROM ${tableName()} WHERE expiry_at < :cutoff") - .param("cutoff", cutoff) - .update() - - override fun deleteByIdIn(ids: Collection) { - if (ids.isEmpty()) return - jdbcClient.sql("DELETE FROM ${tableName()} WHERE id IN (:ids)").param("ids", ids).update() - } -} diff --git a/ogiri-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ogiri-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index dff3e2c..0000000 --- a/ogiri-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -com.quantipixels.ogiri.jdbc.OgiriJdbcAutoConfiguration diff --git a/ogiri-jdbc/src/test/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepositoryTest.kt b/ogiri-jdbc/src/test/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepositoryTest.kt deleted file mode 100644 index f7ea2b4..0000000 --- a/ogiri-jdbc/src/test/kotlin/com/quantipixels/ogiri/jdbc/OgiriJdbcTokenRepositoryTest.kt +++ /dev/null @@ -1,541 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jdbc - -import java.time.Instant -import javax.sql.DataSource -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.jdbc.core.RowMapper -import org.springframework.jdbc.core.simple.JdbcClient -import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder -import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType - -class OgiriJdbcTokenRepositoryTest { - - private val dataSource: DataSource = - EmbeddedDatabaseBuilder() - .setType(EmbeddedDatabaseType.H2) - .addScript("classpath:ogiri/db/ogiri-user-tokens-h2.sql") - .build() - - private val jdbcClient = JdbcClient.create(dataSource) - - private val repo = - object : OgiriJdbcTokenRepository(jdbcClient) { - override fun tableName() = "user_tokens" - - override fun rowMapper() = RowMapper { rs, _ -> - OgiriBaseTokenRow( - id = rs.getLong("id"), - userId = rs.getLong("user_id"), - client = rs.getString("client"), - token = rs.getString("token_hash"), - tokenType = rs.getString("token_type"), - expiryAt = rs.getTimestamp("expiry_at").toInstant(), - tokenUpdatedAt = rs.getTimestamp("token_updated_at").toInstant(), - createdAt = rs.getTimestamp("created_at").toInstant(), - updatedAt = rs.getTimestamp("updated_at").toInstant(), - ) - .apply { - tokenSubtype = rs.getString("token_subtype") - previousToken = rs.getString("previous_token_hash") - lastToken = rs.getString("last_token_hash") - lastUsedAt = rs.getTimestamp("last_used_at")?.toInstant() - } - } - } - - @Test - fun `save inserts new token and returns it with generated id`() { - val token = - OgiriBaseTokenRow( - userId = 1L, - client = "web", - token = "hashedtoken", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - ) - - val saved = repo.save(token) - - assertThat(saved.id).isGreaterThan(0L) - assertThat(saved.userId).isEqualTo(1L) - assertThat(saved.client).isEqualTo("web") - assertThat(saved.token).isEqualTo("hashedtoken") - } - - @Test - fun `save updates existing token`() { - val token = - OgiriBaseTokenRow( - userId = 10L, - client = "desktop", - token = "hash1", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - ) - val inserted = repo.save(token) - inserted.token = "hash2" - - val updated = repo.save(inserted) - - assertThat(updated.id).isEqualTo(inserted.id) - assertThat(updated.token).isEqualTo("hash2") - val reloaded = repo.findById(updated.id).get() - assertThat(reloaded.token).isEqualTo("hash2") - } - - @Test - fun `findById returns token when found`() { - val token = - repo.save( - OgiriBaseTokenRow( - userId = 2L, - client = "mobile", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - val found = repo.findById(token.id) - - assertThat(found).isPresent - assertThat(found.get().id).isEqualTo(token.id) - assertThat(found.get().userId).isEqualTo(2L) - } - - @Test - fun `findById returns empty when id does not exist`() { - val result = repo.findById(999999L) - assertThat(result).isEmpty - } - - @Test - fun `deleteById removes token`() { - val token = - repo.save( - OgiriBaseTokenRow( - userId = 3L, - client = "api", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - repo.deleteById(token.id) - - assertThat(repo.findById(token.id)).isEmpty - } - - @Test - fun `findByUserIdOrderByUpdatedAtDesc returns tokens newest first`() { - val tokenA = - repo.save( - OgiriBaseTokenRow( - userId = 20L, - client = "alpha", - token = "ha", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - val tokenB = - repo.save( - OgiriBaseTokenRow( - userId = 20L, - client = "beta", - token = "hb", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - // Force tokenB to have a newer updatedAt - tokenB.token = "hb-updated" - repo.save(tokenB) - - val results = repo.findByUserIdOrderByUpdatedAtDesc(20L) - - assertThat(results).hasSize(2) - assertThat(results[0].id).isEqualTo(tokenB.id) - assertThat(results[1].id).isEqualTo(tokenA.id) - } - - @Test - fun `findByUserIdOrderByUpdatedAtDesc returns empty list when no tokens`() { - val results = repo.findByUserIdOrderByUpdatedAtDesc(21L) - assertThat(results).isEmpty() - } - - @Test - fun `findByUserIdAndClient returns token for matching pair`() { - repo.save( - OgiriBaseTokenRow( - userId = 22L, - client = "webapp", - token = "hw", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - val result = repo.findByUserIdAndClient(22L, "webapp") - - assertThat(result).isPresent - assertThat(result.get().userId).isEqualTo(22L) - assertThat(result.get().client).isEqualTo("webapp") - } - - @Test - fun `findByUserIdAndClient returns empty when no match`() { - val result = repo.findByUserIdAndClient(23L, "nonexistent") - assertThat(result).isEmpty - } - - @Test - fun `findByUserIdAndClientIn returns matching tokens`() { - repo.save( - OgiriBaseTokenRow( - userId = 30L, - client = "c1", - token = "h1", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 30L, - client = "c2", - token = "h2", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 30L, - client = "c3", - token = "h3", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - val results = repo.findByUserIdAndClientIn(30L, listOf("c1", "c2")) - - assertThat(results).hasSize(2) - assertThat(results.map { it.client }).containsExactlyInAnyOrder("c1", "c2") - } - - @Test - fun `findByUserIdAndClientIn returns empty list when no match`() { - val results = repo.findByUserIdAndClientIn(31L, listOf("none")) - assertThat(results).isEmpty() - } - - @Test - fun `findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc returns tokens filtered by subtype`() { - repo.save( - OgiriBaseTokenRow( - userId = 32L, - client = "d1", - token = "hd1", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - ) - .apply { tokenSubtype = "device" }) - repo.save( - OgiriBaseTokenRow( - userId = 32L, - client = "d2", - token = "hd2", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - ) - .apply { tokenSubtype = "device" }) - repo.save( - OgiriBaseTokenRow( - userId = 32L, - client = "d3", - token = "hd3", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - ) - .apply { tokenSubtype = "chat" }) - - val results = repo.findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(32L, "device") - - assertThat(results).hasSize(2) - assertThat(results).allMatch { it.tokenSubtype == "device" } - } - - // Slice 4 — Filter queries - - @Test - fun `findByExpiryAtBefore returns expired tokens`() { - val past = Instant.now().minusSeconds(1) - val future = Instant.now().plusSeconds(3600) - val expired = - repo.save( - OgiriBaseTokenRow( - userId = 40L, - client = "x", - token = "h", - tokenType = "app", - expiryAt = past, - )) - repo.save( - OgiriBaseTokenRow( - userId = 41L, - client = "y", - token = "h", - tokenType = "app", - expiryAt = future, - )) - val cutoff = Instant.now() - - val result = repo.findByExpiryAtBefore(cutoff) - - assertThat(result).anyMatch { it.id == expired.id } - assertThat(result).noneMatch { it.expiryAt.isAfter(cutoff) } - } - - @Test - fun `findByExpiryAtBefore returns empty when nothing expired`() { - assertThat(repo.findByExpiryAtBefore(Instant.now().minusSeconds(9999))).isEmpty() - } - - @Test - fun `findByTokenType returns tokens matching type`() { - repo.save( - OgiriBaseTokenRow( - userId = 42L, - client = "a", - token = "h", - tokenType = "sub", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 42L, - client = "b", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - val subs = repo.findByTokenType("sub") - - assertThat(subs).isNotEmpty - assertThat(subs).allMatch { it.tokenType == "sub" } - } - - @Test - fun `fetchTopExpiredBefore returns at most limit expired tokens`() { - val past = Instant.now().minusSeconds(1) - val future = Instant.now().plusSeconds(3600) - repeat(5) { i -> - repo.save( - OgiriBaseTokenRow( - userId = 70L, - client = "c$i", - token = "h$i", - tokenType = "app", - expiryAt = past.minusSeconds(i.toLong()), - )) - } - repo.save( - OgiriBaseTokenRow( - userId = 70L, - client = "future", - token = "hf", - tokenType = "app", - expiryAt = future, - )) - - val batch = repo.fetchTopExpiredBefore(Instant.now(), 3) - - assertThat(batch).hasSize(3) - assertThat(batch).allMatch { it.expiryAt.isBefore(Instant.now()) } - } - - @Test - fun `fetchTopExpiredBefore returns only expired tokens when fewer than limit exist for user`() { - val past = Instant.now().minusSeconds(1) - val future = Instant.now().plusSeconds(3600) - repeat(2) { i -> - repo.save( - OgiriBaseTokenRow( - userId = 71L, - client = "c$i", - token = "h$i", - tokenType = "app", - expiryAt = past, - )) - } - repo.save( - OgiriBaseTokenRow( - userId = 71L, - client = "future-71", - token = "hf71", - tokenType = "app", - expiryAt = future, - )) - - val batch = repo.fetchTopExpiredBefore(Instant.now(), 100) - - // Our two expired tokens must be present; the future token must not be - assertThat(batch.filter { it.userId == 71L }).hasSize(2) - assertThat(batch.none { it.userId == 71L && it.client == "future-71" }).isTrue() - assertThat(batch.size).isLessThanOrEqualTo(100) - } - - // Slice 5 — Delete operations - - @Test - fun `deleteByUserIdAndClient removes matching token`() { - repo.save( - OgiriBaseTokenRow( - userId = 50L, - client = "web", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 50L, - client = "mobile", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - repo.deleteByUserIdAndClient(50L, "web") - - assertThat(repo.findByUserIdAndClient(50L, "web")).isEmpty - assertThat(repo.findByUserIdAndClient(50L, "mobile")).isPresent - } - - @Test - fun `deleteByUserIdAndClientIn removes matching tokens`() { - repo.save( - OgiriBaseTokenRow( - userId = 51L, - client = "a", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 51L, - client = "b", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 51L, - client = "c", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - repo.deleteByUserIdAndClientIn(51L, listOf("a", "b")) - - assertThat(repo.findByUserIdOrderByUpdatedAtDesc(51L)).hasSize(1) - assertThat(repo.findByUserIdAndClient(51L, "c")).isPresent - } - - @Test - fun `deleteByUserId removes all tokens for user`() { - repo.save( - OgiriBaseTokenRow( - userId = 52L, - client = "x", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 52L, - client = "y", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - repo.deleteByUserId(52L) - - assertThat(repo.findByUserIdOrderByUpdatedAtDesc(52L)).isEmpty() - } - - // Slice 6 — Aggregates - - @Test - fun `countByUserId returns correct count`() { - repo.save( - OgiriBaseTokenRow( - userId = 60L, - client = "a", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - repo.save( - OgiriBaseTokenRow( - userId = 60L, - client = "b", - token = "h", - tokenType = "app", - expiryAt = Instant.now().plusSeconds(3600), - )) - - assertThat(repo.countByUserId(60L)).isEqualTo(2L) - assertThat(repo.countByUserId(99999L)).isEqualTo(0L) - } - - @Test - fun `deleteByExpiryAtBefore deletes expired tokens and returns count`() { - val past = Instant.now().minusSeconds(1) - val future = Instant.now().plusSeconds(3600) - repo.save( - OgiriBaseTokenRow( - userId = 61L, - client = "a", - token = "h", - tokenType = "app", - expiryAt = past, - )) - repo.save( - OgiriBaseTokenRow( - userId = 61L, - client = "b", - token = "h", - tokenType = "app", - expiryAt = past, - )) - repo.save( - OgiriBaseTokenRow( - userId = 61L, - client = "c", - token = "h", - tokenType = "app", - expiryAt = future, - )) - - val deleted = repo.deleteByExpiryAtBefore(Instant.now()) - - assertThat(deleted).isGreaterThanOrEqualTo(2) - assertThat(repo.findByUserIdOrderByUpdatedAtDesc(61L)).hasSize(1) - } -} diff --git a/ogiri-jpa/build.gradle.kts b/ogiri-jpa/build.gradle.kts deleted file mode 100644 index a320b6d..0000000 --- a/ogiri-jpa/build.gradle.kts +++ /dev/null @@ -1,151 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - kotlin("plugin.spring") - kotlin("plugin.jpa") - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - `maven-publish` - signing - jacoco -} - -group = "com.quantipixels.ogiri" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } - jvmToolchain(17) -} - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -dependencies { - api(project(":ogiri-core")) - api(project(":ogiri-session-core")) - api("org.springframework.boot:spring-boot-starter-data-jpa") - - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testImplementation("com.h2database:h2") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required = true - csv.required = false - html.required = true - } -} - -jacoco { toolVersion = libs.versions.jacoco.get() } - -// Coverage baseline: 0%. Raise this as tests are added. -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { rule { limit { minimum = "0.00".toBigDecimal() } } } -} - -tasks.named("check") { dependsOn(tasks.jacocoTestCoverageVerification) } - -publishing { - publications { - create("mavenJava") { - artifactId = "ogiri-jpa" - artifact(tasks.jar) - artifact(tasks.named("sourcesJar")) - artifact(tasks.named("javadocJar")) - - versionMapping { - usage("java-api") { fromResolutionOf("runtimeClasspath") } - usage("java-runtime") { fromResolutionResult() } - } - - pom { - name.set("ogiri-jpa") - description.set("JPA adapter module for Ogiri token security library.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - withXml { - val dependenciesNode = asNode().appendNode("dependencies") - - // Manually add ogiri-core dependency - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", project.group) - appendNode("artifactId", "ogiri-core") - appendNode("version", project.version) - appendNode("scope", "compile") - } - - // Add Spring Data JPA dependency - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", "org.springframework.boot") - appendNode("artifactId", "spring-boot-starter-data-jpa") - appendNode("scope", "compile") - } - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val signingKey = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val signingPassword = - (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - - if (signingKey != null && signingPassword != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - val pub = publishing.publications.findByName("mavenJava") - if (pub != null) sign(pub) - } -} diff --git a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriBaseTokenEntity.kt b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriBaseTokenEntity.kt deleted file mode 100644 index 5f28659..0000000 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriBaseTokenEntity.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import com.quantipixels.ogiri.security.tokens.OgiriBaseToken -import com.quantipixels.ogiri.security.tokens.OgiriTokenType -import jakarta.persistence.Column -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.MappedSuperclass -import jakarta.persistence.PrePersist -import jakarta.persistence.PreUpdate -import jakarta.persistence.Transient -import java.time.Instant -import org.hibernate.annotations.CreationTimestamp - -/** - * Base JPA entity with all required token fields pre-configured. - * - * Users extend this class and add `@Entity` + `@Table` annotations: - * ```kotlin - * @Entity - * @Table( - * name = "tokens", - * indexes = [ - * Index(name = "idx_tokens_user_id", columnList = "user_id"), - * Index(name = "idx_tokens_expiry", columnList = "expiry_at"), - * ], - * uniqueConstraints = [ - * UniqueConstraint(name = "uk_tokens_user_client", columnNames = ["user_id", "client"]), - * ], - * ) - * class MyToken : OgiriBaseTokenEntity() - * ``` - * - * All 14+ fields with proper JPA annotations are inherited: - * - id (auto-generated) - * - userId, client, token, tokenType - * - expiryAt, tokenUpdatedAt, createdAt, updatedAt - * - previousToken, lastToken, tokenSubtype - * - lastUsedAt - * - plainToken (transient, not persisted) - * - * Timestamps are managed automatically: - * - `createdAt`: Set once on insert via `@CreationTimestamp` - * - `updatedAt`: Updated on every save via `@PrePersist`/`@PreUpdate` - * - `tokenUpdatedAt`: Managed by TokenService during rotation - */ -@MappedSuperclass -abstract class OgiriBaseTokenEntity( - /** Token expiration time (UTC) - should be indexed for cleanup queries. */ - @Column(name = "expiry_at", nullable = false) override var expiryAt: Instant, -) : OgiriBaseToken() { - - /** No-arg constructor required by JPA for proxying — not for use in application code. */ - protected constructor() : this(Instant.EPOCH) - - /** Primary key - auto-generated by database. */ - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) override var id: Long = 0 - - /** User ID - should be indexed for efficient lookups. */ - @Column(name = "user_id", nullable = false) override var userId: Long = 0 - - /** Client/application identifier - part of unique constraint with userId. */ - @Column(name = "client", nullable = false, length = 64) override var client: String = "" - - /** Hashed token value (never plaintext). */ - @Column(name = "token_hash", nullable = false, length = 512) override var token: String = "" - - /** Token type classifier ("app" or "sub"). */ - @Column(name = "token_type", nullable = false, length = 16) - override var tokenType: String = OgiriTokenType.APP.label - - /** Timestamp when token was created. Auto-populated by Hibernate on insert. */ - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - override var createdAt: Instant = Instant.EPOCH - - /** Timestamp when token was last updated. Auto-updated on any modification. */ - @Column(name = "updated_at", nullable = false) override var updatedAt: Instant = Instant.now() - - /** Timestamp when token rotation last occurred. Updated by TokenService during rotation. */ - @Column(name = "token_updated_at", nullable = false) - override var tokenUpdatedAt: Instant = Instant.now() - - /** Optional sub-token type (e.g., "device", "chat"). */ - @Column(name = "token_subtype", length = 32) override var tokenSubtype: String? = null - - /** - * Previous token hash (for grace period during rotation). Allows old token to work briefly while - * new token is issued. - */ - @Column(name = "last_token_hash", length = 512) override var lastToken: String? = null - - /** Token before last (extended grace period). For additional safety during token rotation. */ - @Column(name = "previous_token_hash", length = 512) override var previousToken: String? = null - - /** - * Last timestamp this token was successfully used for authentication. Useful for monitoring and - * cleanup of stale tokens. - */ - @Column(name = "last_used_at") override var lastUsedAt: Instant? = null - - /** - * Plain (unhashed) token value. NEVER persisted to database. Only exists in-memory temporarily - * during token creation. - */ - @Transient override var plainToken: String? = null - - /** Updates the updatedAt timestamp before persisting or updating. */ - @PrePersist - @PreUpdate - fun updateTimestamp() { - updatedAt = Instant.now() - } -} diff --git a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaAutoConfiguration.kt b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaAutoConfiguration.kt deleted file mode 100644 index c7c4c73..0000000 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaAutoConfiguration.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import com.quantipixels.ogiri.security.session.OgiriJobLease -import com.quantipixels.ogiri.security.session.OgiriSessionAutoConfiguration -import com.quantipixels.ogiri.session.SessionStore -import jakarta.persistence.EntityManager -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Import -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.transaction.PlatformTransactionManager - -@AutoConfiguration( - after = [HibernateJpaAutoConfiguration::class], - before = [OgiriSessionAutoConfiguration::class], -) -@ConditionalOnClass(JpaRepository::class) -@Import(OgiriJpaEntityScanRegistrar::class) -public open class OgiriJpaAutoConfiguration { - @Bean - @ConditionalOnMissingBean(SessionStore::class) - public open fun ogiriJpaSessionStore( - entityManager: EntityManager, - transactionManager: PlatformTransactionManager, - ): SessionStore = OgiriJpaSessionStore(entityManager, transactionManager) - - @Bean - @ConditionalOnMissingBean(OgiriJobLease::class) - public open fun ogiriJpaJobLease( - entityManager: EntityManager, - transactionManager: PlatformTransactionManager, - ): OgiriJobLease = OgiriJpaJobLease(entityManager, transactionManager) -} diff --git a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaEntityScanRegistrar.kt b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaEntityScanRegistrar.kt deleted file mode 100644 index 705f275..0000000 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaEntityScanRegistrar.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory -import org.springframework.beans.factory.support.BeanDefinitionRegistry -import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor -import org.springframework.boot.autoconfigure.AutoConfigurationPackages -import org.springframework.boot.autoconfigure.domain.EntityScanPackages - -internal class OgiriJpaEntityScanRegistrar : BeanDefinitionRegistryPostProcessor { - override fun postProcessBeanDefinitionRegistry(registry: BeanDefinitionRegistry) { - val applicationPackages = - (registry as? ConfigurableListableBeanFactory)?.let { beanFactory -> - runCatching { AutoConfigurationPackages.get(beanFactory) }.getOrDefault(emptyList()) - } - ?: emptyList() - EntityScanPackages.register( - registry, - applicationPackages + OgiriSessionEntity::class.java.packageName, - ) - } - - override fun postProcessBeanFactory(beanFactory: ConfigurableListableBeanFactory): Unit = Unit -} diff --git a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaJobLease.kt b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaJobLease.kt deleted file mode 100644 index 18987d5..0000000 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaJobLease.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import com.quantipixels.ogiri.security.session.OgiriJobLease -import jakarta.persistence.EntityManager -import jakarta.persistence.LockModeType -import java.time.Instant -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.annotation.Propagation -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate - -/** - * Transactional JPA implementation of a cluster-safe [OgiriJobLease]. - * - * Each acquire or release uses an independent transaction and a pessimistic row lock. An expired - * lease may be taken by another owner; an active lease may be renewed by its current owner. - */ -public open class OgiriJpaJobLease( - private val entityManager: EntityManager, - transactionManager: PlatformTransactionManager, -) : OgiriJobLease { - private val rowCreation = - TransactionTemplate(transactionManager).apply { - propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW - isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED - } - - @Transactional(propagation = Propagation.REQUIRES_NEW) - override fun tryAcquire(name: String, owner: String, now: Instant, until: Instant): Boolean { - ensureLeaseRow(name) - val lease = - entityManager.find( - OgiriJobLeaseEntity::class.java, - name, - LockModeType.PESSIMISTIC_WRITE, - ) - ?: throw IllegalStateException("job lease row was not committed") - if (lease.ownerId != null && lease.ownerId != owner && lease.leaseUntil?.isAfter(now) == true) { - return false - } - lease.ownerId = owner - lease.leaseUntil = until - entityManager.flush() - return true - } - - private fun ensureLeaseRow(name: String) { - var insertionFailure: RuntimeException? = null - try { - rowCreation.executeWithoutResult { - if (entityManager.find(OgiriJobLeaseEntity::class.java, name) == null) { - entityManager.persist(OgiriJobLeaseEntity(name, null, null)) - entityManager.flush() - } - } - } catch (failure: RuntimeException) { - insertionFailure = failure - } - if (insertionFailure == null) return - - val concurrentlyInserted = - rowCreation.execute { entityManager.find(OgiriJobLeaseEntity::class.java, name) != null } == - true - if (!concurrentlyInserted) throw insertionFailure - } - - @Transactional(propagation = Propagation.REQUIRES_NEW) - override fun release(name: String, owner: String) { - val lease = - entityManager.find( - OgiriJobLeaseEntity::class.java, - name, - LockModeType.PESSIMISTIC_WRITE, - ) - ?: return - if (lease.ownerId == owner) { - lease.ownerId = null - lease.leaseUntil = null - } - } -} diff --git a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaSessionStore.kt b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaSessionStore.kt deleted file mode 100644 index bbe5317..0000000 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaSessionStore.kt +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.CreateSessionCommand -import com.quantipixels.ogiri.session.CreateSessionResult -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.RevocationReason -import com.quantipixels.ogiri.session.RevokeSessionCommand -import com.quantipixels.ogiri.session.RevokeSessionResult -import com.quantipixels.ogiri.session.RotateSessionCommand -import com.quantipixels.ogiri.session.RotateSessionResult -import com.quantipixels.ogiri.session.SessionId -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.StoredSession -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.session.TenantId -import com.quantipixels.ogiri.session.TokenDigest -import jakarta.persistence.EntityManager -import jakarta.persistence.LockModeType -import jakarta.persistence.PersistenceContext -import java.time.Instant -import org.springframework.dao.DataIntegrityViolationException -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.annotation.Isolation -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate - -/** - * JPA implementation of [SessionStore]. - * - * Subject-scoped pessimistic locking serializes admission-limit decisions, while credential - * rotation and revocation use atomic version-checked updates. - */ -@Transactional -public open class OgiriJpaSessionStore( - @PersistenceContext private val entityManager: EntityManager, - transactionManager: PlatformTransactionManager, -) : SessionStore { - private val lockCreation = - TransactionTemplate(transactionManager).apply { - propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW - isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED - } - @Transactional(isolation = Isolation.SERIALIZABLE) - override fun create(command: CreateSessionCommand): CreateSessionResult { - lockSubject(command.session.subject) - if (findEntityBySelector(command.session.selector) != null) { - return CreateSessionResult.SelectorConflict - } - val active = activeEntities(command.session.subject, command.session.createdAt, locked = true) - val evicted = mutableListOf() - if (active.size >= command.maximumActiveSessions) { - if (!command.evictOldestWhenFull) return CreateSessionResult.LimitReached - active.take(active.size - command.maximumActiveSessions + 1).forEach { - it.revokedAt = command.session.createdAt - it.revocationReason = RevocationReason.SESSION_LIMIT - evicted += SessionId(it.sessionId) - } - } - return try { - val entity = command.session.toEntity() - entityManager.persist(entity) - entityManager.flush() - CreateSessionResult.Created(entity.toDomain(), evicted) - } catch (_: DataIntegrityViolationException) { - CreateSessionResult.SelectorConflict - } - } - - @Transactional(readOnly = true) - override fun findBySelector(selector: String): StoredSession? = - findEntityBySelector(selector)?.toDomain() - - @Transactional(readOnly = true) - override fun findById(sessionId: SessionId): StoredSession? = - entityManager.find(OgiriSessionEntity::class.java, sessionId.value)?.toDomain() - - override fun compareAndRotate(command: RotateSessionCommand): RotateSessionResult { - val updated = - entityManager - .createQuery( - """ - update OgiriSessionEntity s - set s.previousKeyId = s.currentKeyId, - s.previousDigest = s.currentDigest, - s.previousValidUntil = :previousValidUntil, - s.currentKeyId = :replacementKeyId, - s.currentDigest = :replacementDigest, - s.lastUsedAt = :usedAt, - s.recordVersion = s.recordVersion + 1 - where s.sessionId = :sessionId - and s.recordVersion = :expectedVersion - and s.currentKeyId = :expectedKeyId - and s.currentDigest = :expectedDigest - and s.revokedAt is null - """ - .trimIndent()) - .setParameter("previousValidUntil", command.previousValidUntil) - .setParameter("replacementKeyId", command.replacementDigest.keyId) - .setParameter("replacementDigest", command.replacementDigest.value) - .setParameter("usedAt", command.usedAt) - .setParameter("sessionId", command.sessionId.value) - .setParameter("expectedVersion", command.expectedVersion) - .setParameter("expectedKeyId", command.expectedCurrentDigest.keyId) - .setParameter("expectedDigest", command.expectedCurrentDigest.value) - .executeUpdate() - if (updated == 0) { - return if (entityManager.find(OgiriSessionEntity::class.java, command.sessionId.value) == - null) { - RotateSessionResult.Missing - } else { - RotateSessionResult.Conflict - } - } - return RotateSessionResult.Rotated(refreshSession(command.sessionId)) - } - - override fun recordUse(sessionId: SessionId, expectedVersion: Long, usedAt: Instant): Boolean = - entityManager - .createQuery( - """ - update OgiriSessionEntity s - set s.lastUsedAt = - case when s.lastUsedAt < :usedAt then :usedAt else s.lastUsedAt end - where s.sessionId = :sessionId - and s.recordVersion = :expectedVersion - and s.revokedAt is null - """ - .trimIndent()) - .setParameter("usedAt", usedAt) - .setParameter("sessionId", sessionId.value) - .setParameter("expectedVersion", expectedVersion) - .executeUpdate() == 1 - - override fun revoke(command: RevokeSessionCommand): RevokeSessionResult { - val existing = - entityManager.find(OgiriSessionEntity::class.java, command.sessionId.value) - ?: return RevokeSessionResult.Missing - if (existing.revokedAt != null) return RevokeSessionResult.AlreadyRevoked - if (command.expectedVersion != null && existing.recordVersion != command.expectedVersion) { - return RevokeSessionResult.Conflict - } - val updated = - entityManager - .createQuery( - """ - update OgiriSessionEntity s - set s.revokedAt = :revokedAt, - s.revocationReason = :reason, - s.recordVersion = s.recordVersion + 1 - where s.sessionId = :sessionId - and s.revokedAt is null - and (:expectedVersion is null or s.recordVersion = :expectedVersion) - """ - .trimIndent()) - .setParameter("revokedAt", command.revokedAt) - .setParameter("reason", command.reason) - .setParameter("sessionId", command.sessionId.value) - .setParameter("expectedVersion", command.expectedVersion) - .executeUpdate() - if (updated == 0) return RevokeSessionResult.Conflict - return RevokeSessionResult.Revoked(refreshSession(command.sessionId)) - } - - override fun revokeAll( - subject: SubjectRef, - revokedAt: Instant, - reason: RevocationReason, - ): List { - lockSubject(subject) - val active = activeEntities(subject, revokedAt, locked = true) - active.forEach { - it.revokedAt = revokedAt - it.revocationReason = reason - } - entityManager.flush() - return active.map { SessionId(it.sessionId) } - } - - @Transactional(readOnly = true) - override fun listActive(subject: SubjectRef, at: Instant): List = - activeEntities(subject, at, locked = false).map(OgiriSessionEntity::toDomain) - - override fun deleteExpiredPage(before: Instant, limit: Int): Int { - val rows = - entityManager - .createQuery( - """ - select s from OgiriSessionEntity s - where s.expiresAt <= :before - or (s.revokedAt is not null and s.revokedAt <= :before) - order by s.sessionId - """ - .trimIndent(), - OgiriSessionEntity::class.java) - .setParameter("before", before) - .setMaxResults(limit) - .setLockMode(LockModeType.PESSIMISTIC_WRITE) - .resultList - rows.forEach(entityManager::remove) - entityManager.flush() - return rows.size - } - - private fun lockSubject(subject: SubjectRef) { - val key = subject.key() - ensureSubjectLock(key) - val lock = - entityManager.find(OgiriSubjectLockEntity::class.java, key) - ?: throw IllegalStateException("subject lock was not committed") - entityManager.lock(lock, LockModeType.PESSIMISTIC_WRITE) - } - - private fun ensureSubjectLock(key: String) { - var insertionFailure: RuntimeException? = null - try { - lockCreation.executeWithoutResult { - if (entityManager.find(OgiriSubjectLockEntity::class.java, key) == null) { - entityManager.persist(OgiriSubjectLockEntity(key)) - entityManager.flush() - } - } - } catch (failure: RuntimeException) { - insertionFailure = failure - } - if (insertionFailure == null) return - - val concurrentlyInserted = - lockCreation.execute { - entityManager.find(OgiriSubjectLockEntity::class.java, key) != null - } == true - if (!concurrentlyInserted) throw insertionFailure - } - - private fun findEntityBySelector(selector: String): OgiriSessionEntity? = - entityManager - .createQuery( - "select s from OgiriSessionEntity s where s.selector = :selector", - OgiriSessionEntity::class.java) - .setParameter("selector", selector) - .resultList - .firstOrNull() - - private fun refreshSession(sessionId: SessionId): StoredSession { - val entity = - entityManager.find(OgiriSessionEntity::class.java, sessionId.value) - ?: throw IllegalStateException("updated session is missing") - entityManager.refresh(entity) - return entity.toDomain() - } - - private fun activeEntities( - subject: SubjectRef, - at: Instant, - locked: Boolean, - ): List { - val query = - entityManager - .createQuery( - """ - select s from OgiriSessionEntity s - where s.realm = :realm - and ((:tenant is null and s.tenantId is null) or s.tenantId = :tenant) - and s.subjectId = :subject - and s.revokedAt is null - and s.expiresAt > :at - order by s.createdAt, s.sessionId - """ - .trimIndent(), - OgiriSessionEntity::class.java) - .setParameter("realm", subject.realm.value) - .setParameter("tenant", subject.tenantId?.value) - .setParameter("subject", subject.subjectId.value) - .setParameter("at", at) - if (locked) query.lockMode = LockModeType.PESSIMISTIC_WRITE - return query.resultList - } - - private fun SubjectRef.key(): String = - listOf(realm.value, tenantId?.value.orEmpty(), subjectId.value).joinToString("\u001f") -} - -private fun StoredSession.toEntity(): OgiriSessionEntity = - OgiriSessionEntity( - id.value, - selector, - subject.realm.value, - subject.tenantId?.value, - subject.subjectId.value, - client.clientId, - client.label, - client.userAgent, - client.ipAddress, - currentDigest.keyId, - currentDigest.value, - previousDigest?.keyId, - previousDigest?.value, - previousValidUntil, - version, - familyId, - createdAt, - lastUsedAt, - expiresAt, - revokedAt, - revocationReason, - ) - -private fun OgiriSessionEntity.toDomain(): StoredSession = - StoredSession( - SessionId(sessionId), - selector, - SubjectRef(Realm(realm), SubjectId(subjectId), tenantId?.let(::TenantId)), - ClientContext(clientId, clientLabel, userAgent, ipAddress), - TokenDigest(currentKeyId, currentDigest), - previousDigest?.let { TokenDigest(requireNotNull(previousKeyId), it) }, - previousValidUntil, - recordVersion, - familyId, - createdAt, - lastUsedAt, - expiresAt, - revokedAt, - revocationReason, - ) diff --git a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriSessionEntity.kt b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriSessionEntity.kt deleted file mode 100644 index 3f1c05d..0000000 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriSessionEntity.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import com.quantipixels.ogiri.session.RevocationReason -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Index -import jakarta.persistence.Table -import jakarta.persistence.Version -import java.time.Instant - -/** JPA persistence model for an immutable Ogiri session snapshot. */ -@Entity -@Table( - name = "ogiri_sessions", - indexes = - [ - Index(name = "ux_ogiri_sessions_selector", columnList = "selector", unique = true), - Index( - name = "ix_ogiri_sessions_subject", - columnList = "realm, tenant_id, subject_id, revoked_at, expires_at"), - Index(name = "ix_ogiri_sessions_cleanup", columnList = "expires_at, revoked_at"), - ], -) -public class OgiriSessionEntity( - @Id @Column(name = "session_id", length = 36, nullable = false) public var sessionId: String, - @Column(name = "selector", length = 64, nullable = false, unique = true) - public var selector: String, - @Column(name = "realm", length = 63, nullable = false) public var realm: String, - @Column(name = "tenant_id", length = 255) public var tenantId: String?, - @Column(name = "subject_id", length = 255, nullable = false) public var subjectId: String, - @Column(name = "client_id", length = 255, nullable = false) public var clientId: String, - @Column(name = "client_label", length = 255) public var clientLabel: String?, - @Column(name = "user_agent", length = 512) public var userAgent: String?, - @Column(name = "ip_address", length = 64) public var ipAddress: String?, - @Column(name = "current_key_id", length = 64, nullable = false) public var currentKeyId: String, - @Column(name = "current_digest", length = 128, nullable = false) - public var currentDigest: String, - @Column(name = "previous_key_id", length = 64) public var previousKeyId: String?, - @Column(name = "previous_digest", length = 128) public var previousDigest: String?, - @Column(name = "previous_valid_until") public var previousValidUntil: Instant?, - @Version @Column(name = "record_version", nullable = false) public var recordVersion: Long, - @Column(name = "family_id", length = 36, nullable = false) public var familyId: String, - @Column(name = "created_at", nullable = false, updatable = false) public var createdAt: Instant, - @Column(name = "last_used_at", nullable = false) public var lastUsedAt: Instant, - @Column(name = "expires_at", nullable = false) public var expiresAt: Instant, - @Column(name = "revoked_at") public var revokedAt: Instant?, - @Enumerated(EnumType.STRING) - @Column(name = "revocation_reason", length = 32) - public var revocationReason: RevocationReason?, -) { - protected constructor() : - this( - "", - "", - "", - null, - "", - "", - null, - null, - null, - "", - "", - null, - null, - null, - 0, - "", - Instant.EPOCH, - Instant.EPOCH, - Instant.EPOCH, - null, - null, - ) -} - -/** Subject-scoped lock row used to serialize active-session admission decisions across nodes. */ -@Entity -@Table(name = "ogiri_subject_locks") -public class OgiriSubjectLockEntity( - @Id @Column(name = "subject_key", length = 600, nullable = false) public var subjectKey: String, - @Version @Column(name = "record_version", nullable = false) public var recordVersion: Long = 0, -) { - protected constructor() : this("") -} - -/** Durable ownership record backing cluster-safe Ogiri maintenance-job leases. */ -@Entity -@Table(name = "ogiri_job_leases") -public class OgiriJobLeaseEntity( - @Id @Column(name = "job_name", length = 128, nullable = false) public var jobName: String, - @Column(name = "owner_id", length = 64) public var ownerId: String?, - @Column(name = "lease_until") public var leaseUntil: Instant?, - @Version @Column(name = "record_version", nullable = false) public var recordVersion: Long = 0, -) { - protected constructor() : this("", null, null) -} diff --git a/ogiri-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ogiri-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index aaf7015..0000000 --- a/ogiri-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -com.quantipixels.ogiri.jpa.OgiriJpaAutoConfiguration diff --git a/ogiri-jpa/src/main/resources/db/migration/V4__create_ogiri_sessions.sql b/ogiri-jpa/src/main/resources/db/migration/V4__create_ogiri_sessions.sql deleted file mode 100644 index ca46711..0000000 --- a/ogiri-jpa/src/main/resources/db/migration/V4__create_ogiri_sessions.sql +++ /dev/null @@ -1,73 +0,0 @@ --- Copyright (c) 2025 Quanti Pixels --- Licensed under the Apache License, Version 2.0. -CREATE - TABLE - ogiri_subject_locks( - subject_key VARCHAR(600) PRIMARY KEY, - record_version BIGINT NOT NULL DEFAULT 0 - ); - -CREATE - TABLE - ogiri_job_leases( - job_name VARCHAR(128) PRIMARY KEY, - owner_id VARCHAR(64), - lease_until TIMESTAMP WITH TIME ZONE, - record_version BIGINT NOT NULL DEFAULT 0 - ); - -CREATE - TABLE - ogiri_sessions( - session_id VARCHAR(36) PRIMARY KEY, - selector VARCHAR(64) NOT NULL, - realm VARCHAR(63) NOT NULL, - tenant_id VARCHAR(255), - subject_id VARCHAR(255) NOT NULL, - client_id VARCHAR(255) NOT NULL, - client_label VARCHAR(255), - user_agent VARCHAR(512), - ip_address VARCHAR(64), - current_key_id VARCHAR(64) NOT NULL, - current_digest VARCHAR(128) NOT NULL, - previous_key_id VARCHAR(64), - previous_digest VARCHAR(128), - previous_valid_until TIMESTAMP WITH TIME ZONE, - record_version BIGINT NOT NULL DEFAULT 0, - family_id VARCHAR(36) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - last_used_at TIMESTAMP WITH TIME ZONE NOT NULL, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - revoked_at TIMESTAMP WITH TIME ZONE, - revocation_reason VARCHAR(32), - CONSTRAINT ux_ogiri_sessions_selector UNIQUE(selector), - CONSTRAINT ck_ogiri_previous_pair CHECK( - ( - previous_digest IS NULL - AND previous_key_id IS NULL - AND previous_valid_until IS NULL - ) - OR( - previous_digest IS NOT NULL - AND previous_key_id IS NOT NULL - AND previous_valid_until IS NOT NULL - ) - ) - ); - -CREATE - INDEX ix_ogiri_sessions_subject ON - ogiri_sessions( - realm, - tenant_id, - subject_id, - revoked_at, - expires_at - ); - -CREATE - INDEX ix_ogiri_sessions_cleanup ON - ogiri_sessions( - expires_at, - revoked_at - ); diff --git a/ogiri-jpa/src/test/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaStarterIntegrationTest.kt b/ogiri-jpa/src/test/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaStarterIntegrationTest.kt deleted file mode 100644 index 0047fd0..0000000 --- a/ogiri-jpa/src/test/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaStarterIntegrationTest.kt +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.jpa - -import com.quantipixels.ogiri.security.session.OgiriJobLease -import com.quantipixels.ogiri.session.ClientContext -import com.quantipixels.ogiri.session.Realm -import com.quantipixels.ogiri.session.RevocationReason -import com.quantipixels.ogiri.session.RevokeSessionCommand -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.SubjectId -import com.quantipixels.ogiri.session.SubjectRef -import com.quantipixels.ogiri.session.TenantId -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EntityManager -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.Duration -import java.time.Instant -import java.util.concurrent.Callable -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference -import org.hibernate.resource.jdbc.spi.StatementInspector -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.annotation.Bean -import org.springframework.security.core.userdetails.User -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.provisioning.InMemoryUserDetailsManager -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.support.TransactionTemplate - -@SpringBootTest( - classes = [OgiriJpaStarterIntegrationTest.TestApplication::class], - properties = - [ - "spring.datasource.url=jdbc:h2:mem:ogiri-v4;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", - "spring.jpa.hibernate.ddl-auto=create-drop", - "spring.jpa.properties.hibernate.jdbc.time_zone=UTC", - "ogiri.session.enabled=true", - "ogiri.session.token-hash.current-key-id=test", - "ogiri.session.token-hash.keys.test=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - ], -) -class OgiriJpaStarterIntegrationTest { - @Autowired private lateinit var sessions: SessionManager - @Autowired private lateinit var store: SessionStore - @Autowired private lateinit var jobLease: OgiriJobLease - @Autowired private lateinit var entityManager: EntityManager - @Autowired private lateinit var transactionManager: PlatformTransactionManager - @Autowired private lateinit var leaseRaceInspector: LeaseRaceStatementInspector - - @Test - fun `blank consumer can issue authenticate and revoke through default JPA store`() { - val subject = SubjectRef(Realm("users"), SubjectId("user-42")) - val issued = sessions.issue(subject, ClientContext("browser")) - val encoded = issued.credential.encoded(com.quantipixels.ogiri.session.OpaqueTokenCodec()) - - assertEquals("user-42", sessions.authenticate(encoded).subject.subjectId.value) - sessions.revokeAll(subject, com.quantipixels.ogiri.session.RevocationReason.SIGN_OUT_ALL) - assertThrows(com.quantipixels.ogiri.session.SessionError.Revoked::class.java) { - sessions.authenticate(encoded) - } - } - - @Test - fun `record use accepts authoritative versions without moving activity backward`() { - val issued = - sessions.issue( - SubjectRef(Realm("users"), SubjectId("user-42"), TenantId("record-use")), - ClientContext("record-use-browser"), - ) - val initial = store.findById(issued.session.id)!!.lastUsedAt - - assertTrue(store.recordUse(issued.session.id, issued.session.version, initial)) - assertTrue( - store.recordUse( - issued.session.id, - issued.session.version, - initial.minus(Duration.ofSeconds(1)), - )) - assertEquals(initial, store.findById(issued.session.id)?.lastUsedAt) - assertFalse(store.recordUse(issued.session.id, issued.session.version + 1, initial)) - - store.revoke( - RevokeSessionCommand( - issued.session.id, - issued.session.version, - initial.plusSeconds(1), - RevocationReason.ADMINISTRATIVE, - )) - assertFalse(store.recordUse(issued.session.id, issued.session.version, initial.plusSeconds(2))) - } - - @Test - fun `concurrent first lease acquisition creates one row and one owner`() { - val name = "first-lease-race-${System.nanoTime()}" - val now = Instant.parse("2026-01-01T00:00:00Z") - val ready = CountDownLatch(8) - val start = CountDownLatch(1) - val executor = Executors.newFixedThreadPool(8) - val outcomes = - try { - val futures = - List(8) { index -> - executor.submit( - Callable { - ready.countDown() - start.await() - runCatching { - jobLease.tryAcquire(name, "owner-$index", now, now.plusSeconds(60)) - } - }) - } - assertTrue(ready.await(5, TimeUnit.SECONDS)) - start.countDown() - futures.map { it.get(10, TimeUnit.SECONDS) } - } finally { - start.countDown() - executor.shutdownNow() - executor.awaitTermination(10, TimeUnit.SECONDS) - } - - assertTrue(outcomes.all { it.isSuccess }) - assertEquals(1, outcomes.count { it.getOrThrow() }) - val winner = outcomes.indexOfFirst { it.getOrThrow() }.let { "owner-$it" } - assertTrue(jobLease.tryAcquire(name, winner, now.plusSeconds(1), now.plusSeconds(120))) - assertFalse(jobLease.tryAcquire(name, "successor", now.plusSeconds(2), now.plusSeconds(120))) - assertTrue(jobLease.tryAcquire(name, "successor", now.plusSeconds(121), now.plusSeconds(180))) - jobLease.release(name, winner) - assertFalse(jobLease.tryAcquire(name, "third", now.plusSeconds(122), now.plusSeconds(180))) - } - - @Test - fun `expired lease owner cannot release a successor lease`() { - val name = "lease-release-race-${System.nanoTime()}" - val now = Instant.parse("2026-01-01T00:00:00Z") - assertTrue(jobLease.tryAcquire(name, "expired-owner", now, now.plusSeconds(1))) - - val executor = Executors.newSingleThreadExecutor() - val control = leaseRaceInspector.start("lease-release-thread") - val release = - CompletableFuture.runAsync( - { - Thread.currentThread().name = "lease-release-thread" - jobLease.release(name, "expired-owner") - }, - executor, - ) - try { - CompletableFuture.anyOf(control.unsafeSecondSelect, release).get(5, TimeUnit.SECONDS) - assertTrue(jobLease.tryAcquire(name, "successor", now.plusSeconds(2), now.plusSeconds(60))) - control.successorCommitted.complete(Unit) - release.get(5, TimeUnit.SECONDS) - - assertFalse(jobLease.tryAcquire(name, "third", now.plusSeconds(3), now.plusSeconds(60))) - } finally { - control.successorCommitted.complete(Unit) - leaseRaceInspector.stop() - executor.shutdownNow() - executor.awaitTermination(5, TimeUnit.SECONDS) - } - } - - @Test - fun `concurrent first sign-ins share one committed subject lock`() { - val subject = - SubjectRef( - Realm("users"), - SubjectId("user-42"), - TenantId("first-sign-in-race"), - ) - val ready = CountDownLatch(8) - val start = CountDownLatch(1) - val executor = Executors.newFixedThreadPool(8) - val outcomes = - try { - val futures = - List(8) { index -> - executor.submit( - Callable { - ready.countDown() - start.await() - runCatching { sessions.issue(subject, ClientContext("first-sign-in-$index")) } - }) - } - assertTrue(ready.await(5, TimeUnit.SECONDS)) - start.countDown() - futures.map { it.get(10, TimeUnit.SECONDS) } - } finally { - start.countDown() - executor.shutdownNow() - executor.awaitTermination(10, TimeUnit.SECONDS) - } - - assertTrue(outcomes.all { it.isSuccess }) - assertEquals(8, sessions.list(subject).size) - } - - @Test - fun `concurrent JPA rotation commits exactly one successor`() { - val issued = - sessions.issue( - SubjectRef(Realm("users"), SubjectId("user-42")), - ClientContext("parallel-browser"), - ) - val encoded = issued.credential.encoded(com.quantipixels.ogiri.session.OpaqueTokenCodec()) - val executor = Executors.newFixedThreadPool(8) - val outcomes = - try { - executor.invokeAll( - List(20) { - Callable { - runCatching { sessions.rotate(encoded) } - .fold({ "rotated" }, { it::class.simpleName.orEmpty() }) - } - }) - } finally { - executor.shutdown() - } - - assertEquals(1, outcomes.count { it.get() == "rotated" }) - assertEquals(19, outcomes.count { it.get() == "Conflict" }) - } - - @Test - fun `rotation preserves consumer managed entities in an enclosing transaction`() { - val issued = - sessions.issue( - SubjectRef(Realm("users"), SubjectId("user-42"), TenantId("consumer-transaction")), - ClientContext("consumer-transaction-browser"), - ) - val encoded = issued.credential.encoded(com.quantipixels.ogiri.session.OpaqueTokenCodec()) - val recordId = "consumer-${System.nanoTime()}" - - TransactionTemplate(transactionManager).executeWithoutResult { - entityManager.persist(ConsumerRecord(recordId, "before-rotation")) - entityManager.flush() - val record = entityManager.find(ConsumerRecord::class.java, recordId) - - sessions.rotate(encoded) - record.value = "after-rotation" - } - - val stored = - TransactionTemplate(transactionManager).execute { - entityManager.find(ConsumerRecord::class.java, recordId).value - } - assertEquals("after-rotation", stored) - } - - @SpringBootApplication - class TestApplication { - @Bean - fun users(): UserDetailsService = - InMemoryUserDetailsManager( - User.withUsername("user-42").password("{noop}password").roles("USER").build()) - - @Bean - fun leaseRaceStatementInspector(): LeaseRaceStatementInspector = LeaseRaceStatementInspector() - - @Bean - fun hibernatePropertiesCustomizer( - inspector: LeaseRaceStatementInspector - ): HibernatePropertiesCustomizer = HibernatePropertiesCustomizer { - it["hibernate.session_factory.statement_inspector"] = inspector - } - } -} - -@Entity -@Table(name = "consumer_records") -class ConsumerRecord( - @Id val id: String, - @Column(name = "record_value") var value: String, -) { - protected constructor() : this("", "") -} - -class LeaseRaceStatementInspector : StatementInspector { - private val active = AtomicReference() - - fun start(threadName: String): LeaseRaceControl = - LeaseRaceControl(threadName).also { active.set(it) } - - fun stop() { - active.set(null) - } - - override fun inspect(sql: String): String { - val control = active.get() ?: return sql - if (Thread.currentThread().name != control.threadName || - !sql.contains("ogiri_job_leases", ignoreCase = true) || - !sql.trimStart().startsWith("select", ignoreCase = true)) { - return sql - } - if (control.selects.incrementAndGet() == 2) { - control.unsafeSecondSelect.complete(Unit) - control.successorCommitted.get(5, TimeUnit.SECONDS) - } - return sql - } -} - -class LeaseRaceControl(val threadName: String) { - val selects = AtomicInteger() - val unsafeSecondSelect = CompletableFuture() - val successorCommitted = CompletableFuture() -} diff --git a/ogiri-redis/build.gradle.kts b/ogiri-redis/build.gradle.kts deleted file mode 100644 index f580b49..0000000 --- a/ogiri-redis/build.gradle.kts +++ /dev/null @@ -1,164 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - kotlin("plugin.spring") - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - `maven-publish` - signing - jacoco -} - -group = "com.quantipixels.ogiri" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } - jvmToolchain(17) -} - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -configurations.all { - resolutionStrategy.eachDependency { - if (requested.group == "org.testcontainers") { - useVersion("1.20.4") - because("Docker Desktop 29.x dropped API v1.24 support; pinned to a known-compatible release") - } - } -} - -dependencies { - api(project(":ogiri-core")) - // Spring Data Redis is an optional peer dependency — consumers must add it themselves. - // The module won't pull Redis into projects that don't need it. - compileOnly("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.springframework.boot:spring-boot-autoconfigure") - - testImplementation(testFixtures(project(":ogiri-core"))) - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testImplementation("org.springframework.boot:spring-boot-starter-data-redis") - testImplementation("com.redis:testcontainers-redis:2.2.4") - testImplementation("org.testcontainers:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() - // Docker Desktop 4.61+ (engine 29.x) requires minimum API v1.44. - // docker-java defaults to v1.24 which returns 400; override to a supported version. - environment("DOCKER_API_VERSION", "1.44") - finalizedBy(tasks.jacocoTestReport) -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required = true - csv.required = false - html.required = true - } -} - -jacoco { toolVersion = libs.versions.jacoco.get() } - -// Coverage baseline: 95% (raised after Redis integration tests enabled). Raise this as tests are -// added. -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.test) - violationRules { rule { limit { minimum = "0.95".toBigDecimal() } } } -} - -tasks.named("check") { dependsOn(tasks.jacocoTestCoverageVerification) } - -publishing { - publications { - create("mavenJava") { - artifactId = "ogiri-redis" - artifact(tasks.jar) - artifact(tasks.named("sourcesJar")) - artifact(tasks.named("javadocJar")) - - versionMapping { - usage("java-api") { fromResolutionOf("runtimeClasspath") } - usage("java-runtime") { fromResolutionResult() } - } - - pom { - name.set("ogiri-redis") - description.set("Redis-backed token lookup cache for the Ogiri security library.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - withXml { - val dependenciesNode = asNode().appendNode("dependencies") - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", project.group) - appendNode("artifactId", "ogiri-core") - appendNode("version", project.version) - appendNode("scope", "compile") - } - dependenciesNode.appendNode("dependency").apply { - appendNode("groupId", "org.springframework.boot") - appendNode("artifactId", "spring-boot-starter-data-redis") - appendNode("scope", "provided") - } - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val signingKey = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val signingPassword = - (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - - if (signingKey != null && signingPassword != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - val pub = publishing.publications.findByName("mavenJava") - if (pub != null) sign(pub) - } -} diff --git a/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfiguration.kt b/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfiguration.kt deleted file mode 100644 index bdbcb3f..0000000 --- a/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfiguration.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.redis - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.config.OgiriLookupTypeCondition -import com.quantipixels.ogiri.security.session.OgiriRateLimiter -import com.quantipixels.ogiri.security.session.OgiriSessionProperties -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.tokens.OgiriToken -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Conditional -import org.springframework.data.redis.connection.RedisConnectionFactory -import org.springframework.data.redis.core.RedisTemplate -import org.springframework.data.redis.core.StringRedisTemplate - -/** - * Autoconfiguration for the Redis-backed [OgiriTokenLookupCache]. - * - * Activates only when **all** of the following are true: - * - `spring-boot-starter-data-redis` is on the classpath - * - `ogiri.lookup.type=redis` is set in `application.yml` (case-insensitive, explicit opt-in) - * - No `OgiriTokenLookupCache` bean is already registered (custom bean wins) - * - * The bean uses the application's existing [RedisConnectionFactory] — no extra Redis configuration - * is needed beyond the standard `spring.data.redis.*` properties. - */ -@AutoConfiguration -@ConditionalOnClass(RedisTemplate::class) -@ConditionalOnMissingBean(OgiriTokenLookupCache::class) -@Conditional(OgiriRedisAutoConfiguration.OnRedisType::class) -class OgiriRedisAutoConfiguration { - - @Bean - fun ogiriRedisTokenLookupCache( - connectionFactory: RedisConnectionFactory, - properties: OgiriConfigurationProperties, - ): OgiriTokenLookupCache = RedisOgiriTokenLookupCache(connectionFactory, properties) - - internal class OnRedisType : OgiriLookupTypeCondition("redis") -} - -@AutoConfiguration -@ConditionalOnClass(StringRedisTemplate::class) -@ConditionalOnProperty( - prefix = "ogiri.session.rate-limit", - name = ["enabled"], - havingValue = "true", -) -class OgiriRedisRateLimitAutoConfiguration { - @Bean - @ConditionalOnMissingBean(OgiriRateLimiter::class) - fun ogiriRedisRateLimiter( - redis: StringRedisTemplate, - properties: OgiriSessionProperties, - ): OgiriRateLimiter = OgiriRedisRateLimiter(redis, properties) -} diff --git a/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiter.kt b/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiter.kt deleted file mode 100644 index 82d942f..0000000 --- a/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiter.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.redis - -import com.quantipixels.ogiri.security.session.OgiriRateLimitDecision -import com.quantipixels.ogiri.security.session.OgiriRateLimiter -import com.quantipixels.ogiri.security.session.OgiriSessionProperties -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.time.Duration -import java.time.Instant -import java.util.HexFormat -import org.springframework.data.redis.core.StringRedisTemplate -import org.springframework.data.redis.core.script.DefaultRedisScript - -public class OgiriRedisRateLimiter( - private val redis: StringRedisTemplate, - properties: OgiriSessionProperties, -) : OgiriRateLimiter { - private val prefix = "${properties.rateLimit.keyPrefix}${properties.realm}:" - - override fun consume( - key: String, - permits: Long, - window: Duration, - now: Instant, - ): OgiriRateLimitDecision { - require(permits > 0) { "rate-limit permits must be positive" } - require(!window.isNegative && !window.isZero) { "rate-limit window must be positive" } - val redisKey = prefix + sha256(key) - val result = - redis.execute(SCRIPT, listOf(redisKey), window.toMillis().toString()) - ?: throw IllegalStateException("Redis rate-limit script returned no result") - val consumed = (result[0] as Number).toLong() - val ttlMillis = (result[1] as Number).toLong().coerceAtLeast(1) - return OgiriRateLimitDecision( - allowed = consumed <= permits, - remaining = (permits - consumed).coerceAtLeast(0), - retryAfter = Duration.ofMillis(ttlMillis), - ) - } - - private fun sha256(value: String): String = - HexFormat.of() - .formatHex( - MessageDigest.getInstance("SHA-256") - .digest(value.toByteArray(StandardCharsets.UTF_8))) - - private companion object { - private val SCRIPT = - DefaultRedisScript( - """ - local current = redis.call('INCR', KEYS[1]) - if current == 1 then - redis.call('PEXPIRE', KEYS[1], ARGV[1]) - end - local ttl = redis.call('PTTL', KEYS[1]) - return {current, ttl} - """ - .trimIndent(), - List::class.java, - ) - } -} diff --git a/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCache.kt b/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCache.kt deleted file mode 100644 index 03a6a4d..0000000 --- a/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCache.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.redis - -import com.fasterxml.jackson.databind.SerializationFeature -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.spi.OgiriCacheKey -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.tokens.OgiriToken -import java.time.Duration -import org.springframework.data.redis.connection.RedisConnectionFactory -import org.springframework.data.redis.core.RedisTemplate -import org.springframework.data.redis.core.ScanOptions -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer -import org.springframework.data.redis.serializer.StringRedisSerializer - -/** - * Redis-backed implementation of [OgiriTokenLookupCache]. - * - * Stores token entities as JSON in Redis, shared across all application nodes. Token revocations - * are immediately visible to every instance. - * - * Serialization uses [GenericJackson2JsonRedisSerializer], which embeds the concrete class name in - * JSON so the correct `T` subtype is restored on deserialization — no per-deployment type - * configuration needed. - * - * **Key layout:** `ogiri:token:{userId}:{client}` - * - * **evictAll** uses Redis `SCAN` rather than `KEYS` to avoid blocking the server on large - * keyspaces. - * - * Activated by setting: - * ```yaml - * ogiri: - * lookup: - * type: redis - * ``` - */ -class RedisOgiriTokenLookupCache( - connectionFactory: RedisConnectionFactory, - properties: OgiriConfigurationProperties, -) : OgiriTokenLookupCache { - - private val ttl = Duration.ofMinutes(properties.lookup.expiryMinutes) - - private val template = buildTemplate(connectionFactory) - - override fun get(userId: Long, client: String): T? = - template.opsForValue().get(OgiriCacheKey.key(userId, client)) - - override fun put(userId: Long, client: String, token: T) = - template.opsForValue().set(OgiriCacheKey.key(userId, client), token, ttl) - - override fun evict(userId: Long, client: String) { - template.delete(OgiriCacheKey.key(userId, client)) - } - - /** - * Remove all cached token entries for a user via Redis SCAN. - * - * SCAN iterates the keyspace incrementally, avoiding the server block that KEYS causes on large - * datasets. - */ - override fun evictAll(userId: Long) { - val prefix = OgiriCacheKey.prefix(userId) - val options = ScanOptions.scanOptions().match("$prefix*").count(100).build() - val keysToDelete = mutableListOf() - template.execute { connection -> - connection.keyCommands().scan(options).use { cursor -> - cursor.forEachRemaining { keysToDelete.add(String(it)) } - } - } - if (keysToDelete.isNotEmpty()) { - template.delete(keysToDelete) - } - } - - companion object { - @Suppress("UNCHECKED_CAST") - private fun buildTemplate( - connectionFactory: RedisConnectionFactory, - ): RedisTemplate = - (RedisTemplate().also { t -> - t.connectionFactory = connectionFactory - t.keySerializer = StringRedisSerializer() - t.valueSerializer = - GenericJackson2JsonRedisSerializer().configure { mapper -> - mapper.findAndRegisterModules() - mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - } - t.afterPropertiesSet() - } as RedisTemplate) - } -} diff --git a/ogiri-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ogiri-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 07e1070..0000000 --- a/ogiri-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1,2 +0,0 @@ -com.quantipixels.ogiri.security.redis.OgiriRedisAutoConfiguration -com.quantipixels.ogiri.security.redis.OgiriRedisRateLimitAutoConfiguration diff --git a/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfigurationTest.kt b/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfigurationTest.kt deleted file mode 100644 index 7b5e237..0000000 --- a/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisAutoConfigurationTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.redis - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache -import com.quantipixels.ogiri.security.tokens.OgiriToken -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.boot.autoconfigure.AutoConfigurations -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.data.redis.connection.RedisConnectionFactory -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory - -class OgiriRedisAutoConfigurationTest { - - private val contextRunner = - ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OgiriRedisAutoConfiguration::class.java)) - .withUserConfiguration(TestConfig::class.java) - - @Configuration - @EnableConfigurationProperties(OgiriConfigurationProperties::class) - class TestConfig { - /** Stub connection factory — auto-config condition evaluation does not open a connection. */ - @Bean - fun redisConnectionFactory(): RedisConnectionFactory = - LettuceConnectionFactory(RedisStandaloneConfiguration("localhost", 6379)) - } - - @Nested - inner class Activation { - @Test - fun `activates with lowercase redis`() { - contextRunner.withPropertyValues("ogiri.lookup.type=redis").run { context -> - assertTrue(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - - @Test - fun `activates with title case Redis`() { - contextRunner.withPropertyValues("ogiri.lookup.type=Redis").run { context -> - assertTrue(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - - @Test - fun `activates with uppercase REDIS`() { - contextRunner.withPropertyValues("ogiri.lookup.type=REDIS").run { context -> - assertTrue(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - - @Test - fun `activates with mixed whitespace`() { - contextRunner.withPropertyValues("ogiri.lookup.type= Redis ").run { context -> - assertTrue(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - } - - @Nested - inner class NonActivation { - @Test - fun `does not activate when type is absent`() { - contextRunner.run { context -> - assertFalse(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - - @Test - fun `does not activate when type is caffeine`() { - contextRunner.withPropertyValues("ogiri.lookup.type=caffeine").run { context -> - assertFalse(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - - @Test - fun `does not activate when type is unrecognized`() { - contextRunner.withPropertyValues("ogiri.lookup.type=memcached").run { context -> - assertFalse(context.containsBean("ogiriRedisTokenLookupCache")) - } - } - - @Test - fun `custom OgiriTokenLookupCache bean wins over auto-configured redis`() { - contextRunner - .withPropertyValues("ogiri.lookup.type=redis") - .withUserConfiguration(CustomLookupCacheConfig::class.java) - .run { context -> - assertFalse(context.containsBean("ogiriRedisTokenLookupCache")) - assertTrue(context.containsBean("customLookupCache")) - } - } - } - - @Configuration - class CustomLookupCacheConfig { - @Bean fun customLookupCache(): OgiriTokenLookupCache = StubLookupCache() - } - - private class StubLookupCache : OgiriTokenLookupCache { - override fun get(userId: Long, client: String): OgiriToken? = null - - override fun put(userId: Long, client: String, token: OgiriToken) {} - - override fun evict(userId: Long, client: String) {} - - override fun evictAll(userId: Long) {} - } -} diff --git a/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiterTest.kt b/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiterTest.kt deleted file mode 100644 index 97ad78f..0000000 --- a/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiterTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.redis - -import com.quantipixels.ogiri.security.session.OgiriSessionProperties -import com.redis.testcontainers.RedisContainer -import java.time.Duration -import java.time.Instant -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory -import org.springframework.data.redis.core.StringRedisTemplate -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers - -@Testcontainers(disabledWithoutDocker = true) -class OgiriRedisRateLimiterTest { - companion object { - @Container val redis: RedisContainer = RedisContainer("redis:7-alpine") - } - - private lateinit var template: StringRedisTemplate - private lateinit var limiter: OgiriRedisRateLimiter - - @BeforeEach - fun setup() { - val factory = - LettuceConnectionFactory( - RedisStandaloneConfiguration(redis.host, redis.getMappedPort(6379))) - .also { it.afterPropertiesSet() } - template = StringRedisTemplate(factory).also { it.afterPropertiesSet() } - factory.connection.use { it.serverCommands().flushAll() } - val properties = - OgiriSessionProperties( - enabled = true, - realm = "users", - rateLimit = OgiriSessionProperties.RateLimit(enabled = true), - ) - limiter = - OgiriRedisRateLimitAutoConfiguration().ogiriRedisRateLimiter(template, properties) - as OgiriRedisRateLimiter - } - - @Test - fun `atomic bucket allows limit then returns retry after`() { - val now = Instant.parse("2026-07-11T00:00:00Z") - - val first = limiter.consume("sign-in:ip:127.0.0.1", 2, Duration.ofSeconds(30), now) - val second = limiter.consume("sign-in:ip:127.0.0.1", 2, Duration.ofSeconds(30), now) - val denied = limiter.consume("sign-in:ip:127.0.0.1", 2, Duration.ofSeconds(30), now) - - assertTrue(first.allowed) - assertEquals(1, first.remaining) - assertTrue(second.allowed) - assertFalse(denied.allowed) - assertEquals(0, denied.remaining) - assertTrue(!denied.retryAfter.isZero) - } - - @Test - fun `different normalized scopes use isolated buckets`() { - val now = Instant.EPOCH - assertTrue(limiter.consume("sign-in:ip:one", 1, Duration.ofMinutes(1), now).allowed) - assertTrue(limiter.consume("sign-in:ip:two", 1, Duration.ofMinutes(1), now).allowed) - } - - @Test - fun `invalid bucket policy is rejected before Redis`() { - assertThrows(IllegalArgumentException::class.java) { - limiter.consume("key", 0, Duration.ofMinutes(1), Instant.EPOCH) - } - assertThrows(IllegalArgumentException::class.java) { - limiter.consume("key", 1, Duration.ZERO, Instant.EPOCH) - } - } -} diff --git a/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCacheTest.kt b/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCacheTest.kt deleted file mode 100644 index a8811d3..0000000 --- a/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/RedisOgiriTokenLookupCacheTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.security.redis - -import com.quantipixels.ogiri.security.OgiriStubToken -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.redis.testcontainers.RedisContainer -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers - -@Testcontainers(disabledWithoutDocker = true) -class RedisOgiriTokenLookupCacheTest { - - companion object { - @Container val redis: RedisContainer = RedisContainer("redis:7-alpine") - } - - private lateinit var cache: RedisOgiriTokenLookupCache - - @BeforeEach - fun setup() { - val properties = - OgiriConfigurationProperties().apply { - lookup.maxSize = 1000 - lookup.expiryMinutes = 5 - } - val factory = - LettuceConnectionFactory( - RedisStandaloneConfiguration(redis.host, redis.getMappedPort(6379))) - .also { it.afterPropertiesSet() } - cache = RedisOgiriTokenLookupCache(factory, properties) - // Flush all keys before each test for isolation - factory.connection.use { it.serverCommands().flushAll() } - } - - @Nested - inner class GetAndPut { - @Test - fun `get returns null on cache miss`() { - assertNull(cache.get(1L, "client-a")) - } - - @Test - fun `put then get returns the stored token`() { - val token = OgiriStubToken(userId = 1L, client = "client-a") - cache.put(1L, "client-a", token) - val retrieved = cache.get(1L, "client-a") - assertNotNull(retrieved) - assertEquals("client-a", retrieved?.client) - } - - @Test - fun `put overwrites existing entry`() { - cache.put(1L, "c", OgiriStubToken(token = "hash1")) - cache.put(1L, "c", OgiriStubToken(token = "hash2")) - assertEquals("hash2", cache.get(1L, "c")?.token) - } - - @Test - fun `different users do not share entries`() { - cache.put(1L, "shared", OgiriStubToken(userId = 1L)) - assertNull(cache.get(2L, "shared")) - } - } - - @Nested - inner class Eviction { - @Test - fun `evict removes single entry`() { - cache.put(1L, "to-evict", OgiriStubToken()) - cache.evict(1L, "to-evict") - assertNull(cache.get(1L, "to-evict")) - } - - @Test - fun `evict does not affect other entries`() { - cache.put(1L, "keep", OgiriStubToken(client = "keep")) - cache.put(1L, "remove", OgiriStubToken(client = "remove")) - cache.evict(1L, "remove") - assertNotNull(cache.get(1L, "keep")) - assertNull(cache.get(1L, "remove")) - } - - @Test - fun `evictAll removes all entries for a user`() { - cache.put(1L, "c1", OgiriStubToken(client = "c1")) - cache.put(1L, "c2", OgiriStubToken(client = "c2")) - cache.put(2L, "c1", OgiriStubToken(userId = 2L, client = "c1")) - - cache.evictAll(1L) - - assertNull(cache.get(1L, "c1")) - assertNull(cache.get(1L, "c2")) - assertNotNull(cache.get(2L, "c1"), "Other user entries must not be evicted") - } - } -} diff --git a/ogiri-session-core/build.gradle.kts b/ogiri-session-core/build.gradle.kts deleted file mode 100644 index 329bd9c..0000000 --- a/ogiri-session-core/build.gradle.kts +++ /dev/null @@ -1,93 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - jacoco - `java-library` - `maven-publish` - signing -} - -group = "com.quantipixels.ogiri" - -java { - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - explicitApi = ExplicitApiMode.Strict - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - freeCompilerArgs.add("-Xjvm-default=all") - } - jvmToolchain(17) -} - -dependencies { - testImplementation(kotlin("test")) - testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) -} - -jacoco { toolVersion = libs.versions.jacoco.get() } - -publishing { - publications { - create("mavenJava") { - from(components["java"]) - pom { - name.set("ogiri-session-core") - description.set("Spring-free session state machine and persistence contract for Ogiri.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val key = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val password = (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - if (key != null && password != null) { - useInMemoryPgpKeys(key, password) - sign(publishing.publications["mavenJava"]) - } -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/IdentifierGenerator.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/IdentifierGenerator.kt deleted file mode 100644 index f2aa6d7..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/IdentifierGenerator.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.security.SecureRandom -import java.util.UUID - -/** Supplies opaque identifiers for sessions, token families, and lifecycle events. */ -public fun interface IdentifierGenerator { - /** Returns a new identifier suitable for durable storage. */ - public fun next(): String -} - -/** Generates UUID-shaped identifiers from the supplied cryptographically secure random source. */ -public class SecureRandomIdentifierGenerator( - private val secureRandom: SecureRandom, -) : IdentifierGenerator { - override fun next(): String = UUID(secureRandom.nextLong(), secureRandom.nextLong()).toString() -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/OgiriSessions.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/OgiriSessions.kt deleted file mode 100644 index f97482f..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/OgiriSessions.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.time.Duration - -/** - * Java-friendly factories for the session domain API. - * - * Kotlin callers may construct the corresponding value types directly. - */ -public object OgiriSessions { - @JvmStatic - @JvmOverloads - /** Creates a validated subject reference from primitive values. */ - public fun subject(realm: String, subjectId: String, tenantId: String? = null): SubjectRef = - SubjectRef(Realm(realm), SubjectId(subjectId), tenantId?.let(::TenantId)) - - @JvmStatic - @JvmOverloads - /** Creates validated client metadata from primitive values. */ - public fun client( - clientId: String, - label: String? = null, - userAgent: String? = null, - ipAddress: String? = null, - ): ClientContext = ClientContext(clientId, label, userAgent, ipAddress) - - /** Starts a builder initialized with the default [SessionPolicy]. */ - @JvmStatic public fun policy(): PolicyBuilder = PolicyBuilder() - - /** Fluent Java builder for [SessionPolicy]. */ - public class PolicyBuilder internal constructor() { - private var lifetime: Duration = Duration.ofDays(14) - private var grace: Duration = Duration.ofSeconds(5) - private var maximum: Int = 10 - private var evictOldest: Boolean = true - - /** Sets how long newly issued sessions remain valid. */ - public fun lifetime(value: Duration): PolicyBuilder = apply { lifetime = value } - - /** Sets how long the immediately previous credential remains valid after rotation. */ - public fun previousVersionGrace(value: Duration): PolicyBuilder = apply { grace = value } - - /** Sets the maximum number of active sessions allowed for one subject. */ - public fun maximumActiveSessions(value: Int): PolicyBuilder = apply { maximum = value } - - /** - * Chooses whether issuance evicts the oldest session when the active-session limit is reached. - */ - public fun evictOldestWhenFull(value: Boolean): PolicyBuilder = apply { evictOldest = value } - - /** Builds and validates an immutable policy. */ - public fun build(): SessionPolicy = SessionPolicy(lifetime, grace, maximum, evictOldest) - } -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionManager.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionManager.kt deleted file mode 100644 index 53a5ede..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionManager.kt +++ /dev/null @@ -1,363 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.security.SecureRandom -import java.time.Clock -import java.time.Duration -import java.time.Instant - -/** Determines whether a subject is currently permitted to authenticate. */ -public fun interface SubjectStatusChecker { - /** Returns `false` when issuance and authentication must reject [subject]. */ - public fun isAllowed(subject: SubjectRef): Boolean -} - -/** Security and admission policy applied by [SessionManager]. */ -public data class SessionPolicy -@JvmOverloads -public constructor( - public val lifetime: Duration = Duration.ofDays(14), - public val previousVersionGrace: Duration = Duration.ofSeconds(5), - public val maximumActiveSessions: Int = 10, - public val evictOldestWhenFull: Boolean = true, -) { - init { - require(!lifetime.isNegative && !lifetime.isZero) { "session lifetime must be positive" } - require(!previousVersionGrace.isNegative) { "previous-version grace must not be negative" } - require(maximumActiveSessions > 0) { "maximum active sessions must be positive" } - } -} - -/** Lifecycle action emitted after a successful session-store operation. */ -public enum class SessionEventAction { - ISSUED, - AUTHENTICATED, - ROTATED, - REVOKED, - REVOKED_ALL, - REUSE_DETECTED, - EVICTED, -} - -/** Immutable audit event describing a committed session lifecycle change. */ -public data class SessionEvent( - public val eventId: String, - public val occurredAt: Instant, - public val action: SessionEventAction, - public val subject: SubjectRef, - public val sessionId: SessionId?, - public val familyId: String?, - public val reason: RevocationReason? = null, - public val correlationId: String? = null, -) - -/** Receives session lifecycle events after the corresponding store command succeeds. */ -public fun interface SessionEventPublisher { - /** Called only after the store command returns successfully. */ - public fun publish(event: SessionEvent) -} - -/** Event publisher used when session lifecycle events are not observed. */ -public object NoOpSessionEventPublisher : SessionEventPublisher { - override fun publish(event: SessionEvent): Unit = Unit -} - -/** - * Coordinates session issuance, authentication, rotation, and revocation. - * - * Credentials are split into a non-secret selector and a hashed verifier. Mutating operations use - * optimistic versions supplied by [SessionStore], and lifecycle events are published only after a - * store operation commits. - */ -public class SessionManager -@JvmOverloads -public constructor( - private val store: SessionStore, - private val codec: TokenCodec, - private val hasher: TokenHasher, - private val statusChecker: SubjectStatusChecker, - private val clock: Clock, - private val policy: SessionPolicy = SessionPolicy(), - private val events: SessionEventPublisher = NoOpSessionEventPublisher, - private val identifiers: IdentifierGenerator = SecureRandomIdentifierGenerator(SecureRandom()), -) { - /** - * Issues a new session for [subject] and [client]. - * - * The store atomically enforces [SessionPolicy.maximumActiveSessions]. Selector collisions are - * retried with freshly generated credential material. - * - * @throws SessionError.SubjectUnavailable when the subject is not allowed to authenticate - * @throws SessionError.SessionLimitReached when admission is full and eviction is disabled - */ - public fun issue(subject: SubjectRef, client: ClientContext): IssuedSession { - if (!statusChecker.isAllowed(subject)) throw SessionError.SubjectUnavailable() - val now = clock.instant() - val generated = codec.generate() - val sessionId = SessionId(identifiers.next()) - val familyId = identifiers.next() - val session = - StoredSession( - id = sessionId, - selector = generated.selector, - subject = subject, - client = client, - currentDigest = hasher.digest(generated.verifier), - previousDigest = null, - previousValidUntil = null, - version = 0, - familyId = familyId, - createdAt = now, - lastUsedAt = now, - expiresAt = now.plus(policy.lifetime), - ) - return when (val result = - store.create( - CreateSessionCommand( - session, - policy.maximumActiveSessions, - policy.evictOldestWhenFull, - ))) { - is CreateSessionResult.Created -> { - result.evictedSessionIds.forEach { - publish( - now, SessionEventAction.EVICTED, subject, it, null, RevocationReason.SESSION_LIMIT) - } - publish(now, SessionEventAction.ISSUED, subject, sessionId, familyId) - IssuedSession( - result.session, - SessionCredential(sessionId, generated.selector, generated.verifier), - ) - } - CreateSessionResult.LimitReached -> throw SessionError.SessionLimitReached() - CreateSessionResult.SelectorConflict -> issue(subject, client) - } - } - - /** - * Verifies an encoded credential and records successful use. - * - * Reusing a previous credential after its grace deadline revokes the session as token reuse. - * - * @throws SessionError for malformed, invalid, expired, revoked, or disallowed credentials - */ - public fun authenticate(encodedCredential: String): AuthenticatedSession { - val decoded = decode(encodedCredential) - val session = store.findBySelector(decoded.selector) ?: throw SessionError.InvalidCredential() - val now = clock.instant() - if (session.revokedAt != null) throw SessionError.Revoked() - if (!now.isBefore(session.expiresAt)) throw SessionError.Expired() - if (!statusChecker.isAllowed(session.subject)) { - store.revoke( - RevokeSessionCommand( - session.id, - session.version, - now, - RevocationReason.ACCOUNT_DISABLED, - )) - throw SessionError.SubjectUnavailable() - } - - val current = hasher.matches(decoded.verifier, session.currentDigest) - val previous = session.previousDigest?.let { hasher.matches(decoded.verifier, it) } ?: false - if (!current && previous) { - if (session.previousValidUntil?.let(now::isBefore) != true) { - store.revoke(RevokeSessionCommand(session.id, null, now, RevocationReason.TOKEN_REUSE)) - publish( - now, - SessionEventAction.REUSE_DETECTED, - session.subject, - session.id, - session.familyId, - RevocationReason.TOKEN_REUSE, - ) - throw SessionError.ReuseDetected() - } - } else if (!current) { - throw SessionError.InvalidCredential() - } - - if (!store.recordUse(session.id, session.version, now)) throw SessionError.Conflict() - publish(now, SessionEventAction.AUTHENTICATED, session.subject, session.id, session.familyId) - return session.authenticated(usedPreviousVersion = previous) - } - - /** - * Replaces the verifier while retaining the same session identity and selector. - * - * @throws SessionError.Conflict when another request already rotated this version - */ - public fun rotate(encodedCredential: String): IssuedSession { - val decoded = decode(encodedCredential) - val session = store.findBySelector(decoded.selector) ?: throw SessionError.InvalidCredential() - val now = clock.instant() - if (!session.isActive(now)) { - if (session.revokedAt != null) throw SessionError.Revoked() else throw SessionError.Expired() - } - if (!statusChecker.isAllowed(session.subject)) throw SessionError.SubjectUnavailable() - if (!hasher.matches(decoded.verifier, session.currentDigest)) { - if (session.previousDigest?.let { hasher.matches(decoded.verifier, it) } == true) { - throw SessionError.Conflict() - } - throw SessionError.InvalidCredential() - } - - val successor = codec.generate() - val result = - store.compareAndRotate( - RotateSessionCommand( - sessionId = session.id, - expectedVersion = session.version, - expectedCurrentDigest = session.currentDigest, - replacementDigest = hasher.digest(successor.verifier), - previousValidUntil = now.plus(policy.previousVersionGrace), - usedAt = now, - )) - val rotated = - when (result) { - is RotateSessionResult.Rotated -> result.session - RotateSessionResult.Conflict -> throw SessionError.Conflict() - RotateSessionResult.Missing -> throw SessionError.InvalidCredential() - } - publish(now, SessionEventAction.ROTATED, rotated.subject, rotated.id, rotated.familyId) - return IssuedSession( - rotated, - SessionCredential(rotated.id, decoded.selector, successor.verifier), - ) - } - - /** Revokes the authenticated session, returning whether this call changed stored state. */ - public fun revoke(authenticated: AuthenticatedSession): Boolean { - val now = clock.instant() - return when (store.revoke( - RevokeSessionCommand( - authenticated.sessionId, - authenticated.version, - now, - RevocationReason.SIGN_OUT, - ))) { - is RevokeSessionResult.Revoked -> { - publish( - now, - SessionEventAction.REVOKED, - authenticated.subject, - authenticated.sessionId, - authenticated.familyId, - RevocationReason.SIGN_OUT, - ) - true - } - RevokeSessionResult.AlreadyRevoked, - RevokeSessionResult.Missing -> false - RevokeSessionResult.Conflict -> - when (store.revoke( - RevokeSessionCommand( - authenticated.sessionId, - null, - now, - RevocationReason.SIGN_OUT, - ))) { - is RevokeSessionResult.Revoked -> true - else -> false - } - } - } - - /** Revokes [sessionId] only when it belongs to [subject]. */ - public fun revoke( - subject: SubjectRef, - sessionId: SessionId, - reason: RevocationReason = RevocationReason.ADMINISTRATIVE, - ): Boolean { - val session = store.findById(sessionId) ?: return false - if (session.subject != subject) return false - val now = clock.instant() - return when (store.revoke(RevokeSessionCommand(sessionId, null, now, reason))) { - is RevokeSessionResult.Revoked -> { - publish( - now, - SessionEventAction.REVOKED, - subject, - sessionId, - session.familyId, - reason, - ) - true - } - else -> false - } - } - - /** Revokes every active session for the subject except [authenticated]. */ - public fun revokeOthers( - authenticated: AuthenticatedSession, - reason: RevocationReason = RevocationReason.ADMINISTRATIVE, - ): List = - list(authenticated.subject) - .asSequence() - .filter { it.id != authenticated.sessionId } - .filter { revoke(authenticated.subject, it.id, reason) } - .map { it.id } - .toList() - - /** Revokes all active sessions owned by [subject] and returns their IDs. */ - public fun revokeAll(subject: SubjectRef, reason: RevocationReason): List { - val now = clock.instant() - val revoked = store.revokeAll(subject, now, reason) - if (revoked.isNotEmpty()) { - publish(now, SessionEventAction.REVOKED_ALL, subject, null, null, reason) - } - return revoked - } - - /** Lists active sessions for [subject] at the manager clock's current instant. */ - public fun list(subject: SubjectRef): List = - store.listActive(subject, clock.instant()) - - /** Deletes one bounded page of expired or revoked sessions. */ - public fun cleanupPage(limit: Int): Int { - require(limit in 1..10_000) { "cleanup page size must be between 1 and 10000" } - return store.deleteExpiredPage(clock.instant(), limit) - } - - private fun decode(encodedCredential: String): DecodedCredential = - try { - codec.decode(encodedCredential) - } catch (_: IllegalArgumentException) { - throw SessionError.InvalidCredential() - } - - private fun StoredSession.authenticated(usedPreviousVersion: Boolean): AuthenticatedSession = - AuthenticatedSession(id, subject, client, version, familyId, usedPreviousVersion) - - private fun publish( - now: Instant, - action: SessionEventAction, - subject: SubjectRef, - sessionId: SessionId?, - familyId: String?, - reason: RevocationReason? = null, - ) { - events.publish( - SessionEvent( - identifiers.next(), - now, - action, - subject, - sessionId, - familyId, - reason, - )) - } -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionModel.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionModel.kt deleted file mode 100644 index 751ae15..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionModel.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.time.Instant - -/** Stable, Java-compatible identifier assigned to a persisted session. */ -public class SessionId(public val value: String) { - init { - require(value.isNotBlank()) { "session ID must not be blank" } - } - - public override fun equals(other: Any?): Boolean = - this === other || (other is SessionId && value == other.value) - - public override fun hashCode(): Int = value.hashCode() - - public override fun toString(): String = value -} - -/** Application-defined identifier of the account or principal that owns a session. */ -@JvmInline -public value class SubjectId(public val value: String) { - init { - require(value.isNotBlank()) { "subject ID must not be blank" } - } - - public override fun toString(): String = value -} - -/** - * Namespace in which a [SubjectId] is interpreted. - * - * Realm names are safe for storage keys and must contain 1–63 lowercase ASCII letters, digits, - * dots, underscores, or hyphens. - */ -@JvmInline -public value class Realm(public val value: String) { - init { - require(value.matches(REALM_PATTERN)) { - "realm must contain only lowercase ASCII letters, digits, dots, underscores, or hyphens" - } - } - - public override fun toString(): String = value - - private companion object { - private val REALM_PATTERN = Regex("[a-z0-9][a-z0-9._-]{0,62}") - } -} - -/** Optional tenant boundary used to distinguish otherwise identical subjects. */ -@JvmInline -public value class TenantId(public val value: String) { - init { - require(value.isNotBlank()) { "tenant ID must not be blank" } - } - - public override fun toString(): String = value -} - -/** - * Canonical identity of a session owner. - * - * @property realm namespace of the subject identifier - * @property subjectId identifier within [realm] - * @property tenantId optional tenant boundary - */ -public data class SubjectRef -@JvmOverloads -public constructor( - public val realm: Realm, - public val subjectId: SubjectId, - public val tenantId: TenantId? = null, -) { - public fun realmName(): String = realm.value - - public fun subjectValue(): String = subjectId.value - - public fun tenantValue(): String? = tenantId?.value -} - -/** - * Metadata identifying the client on which a session was issued. - * - * Only [clientId] participates in client-targeted revocation. The remaining fields are descriptive - * metadata suitable for session-management views and audit events. - */ -public data class ClientContext -@JvmOverloads -public constructor( - public val clientId: String, - public val label: String? = null, - public val userAgent: String? = null, - public val ipAddress: String? = null, -) { - init { - require(clientId.isNotBlank()) { "client ID must not be blank" } - } -} - -/** - * One-way verifier digest stored with a session. - * - * @property keyId identifies the hashing key so credentials remain verifiable during key rotation - * @property value encoded digest produced by [TokenHasher] - */ -public data class TokenDigest(public val keyId: String, public val value: String) - -/** - * Complete persisted state of a session. - * - * [currentDigest] authenticates the current credential. During rotation, [previousDigest] remains - * valid only until [previousValidUntil], allowing a bounded grace window for concurrent requests. A - * session is immutable; stores replace it using optimistic [version] checks. - */ -public data class StoredSession( - public val id: SessionId, - public val selector: String, - public val subject: SubjectRef, - public val client: ClientContext, - public val currentDigest: TokenDigest, - public val previousDigest: TokenDigest?, - public val previousValidUntil: Instant?, - public val version: Long, - public val familyId: String, - public val createdAt: Instant, - public val lastUsedAt: Instant, - public val expiresAt: Instant, - public val revokedAt: Instant? = null, - public val revocationReason: RevocationReason? = null, -) { - init { - require(version >= 0) { "session version must not be negative" } - require(previousDigest != null || previousValidUntil == null) { - "a previous deadline requires a previous digest" - } - require(previousDigest == null || previousValidUntil != null) { - "a previous digest requires a fixed deadline" - } - } - - /** Returns whether this session is neither revoked nor expired at [at]. */ - public fun isActive(at: Instant): Boolean = revokedAt == null && at.isBefore(expiresAt) -} - -/** Machine-readable reason recorded when a session is revoked. */ -public enum class RevocationReason { - SIGN_OUT, - SIGN_OUT_ALL, - ACCOUNT_DISABLED, - PASSWORD_CHANGED, - TOKEN_REUSE, - SESSION_LIMIT, - ADMINISTRATIVE, - EXPIRED, - PARENT_REVOKED, -} - -/** - * Secret credential returned to a client when a session is issued or rotated. - * - * The verifier is sensitive and must not be persisted or logged in plaintext. - */ -public data class SessionCredential( - public val sessionId: SessionId, - public val selector: String, - public val verifier: String, -) { - /** Encodes the public selector and secret verifier for transport using [codec]. */ - public fun encoded(codec: TokenCodec): String = codec.encode(selector, verifier) - - /** Returns a diagnostic representation without credential material. */ - public override fun toString(): String = - "SessionCredential(sessionId=$sessionId, selector=[REDACTED], verifier=[REDACTED])" -} - -/** Newly persisted session state together with the credential that authenticates it. */ -public data class IssuedSession( - public val session: StoredSession, - public val credential: SessionCredential, -) { - /** Returns a diagnostic representation without session credential material. */ - public override fun toString(): String = - "IssuedSession(sessionId=${session.id}, credential=$credential)" -} - -/** - * Non-secret identity established after successful credential verification. - * - * [usedPreviousVersion] is true when authentication succeeded within the configured rotation grace - * window and callers should return the latest credential rather than rotate again. - */ -public data class AuthenticatedSession( - public val sessionId: SessionId, - public val subject: SubjectRef, - public val client: ClientContext, - public val version: Long, - public val familyId: String, - public val usedPreviousVersion: Boolean, -) - -/** - * Expected session failure with a stable [code] suitable for protocol error responses. - * - * Concrete subclasses deliberately contain no credential or persistence details. - */ -public sealed class SessionError(public val code: String, message: String) : - RuntimeException(message) { - public class InvalidCredential : - SessionError("invalid_credential", "The session credential is invalid") - - public class Expired : SessionError("session_expired", "The session has expired") - - public class Revoked : SessionError("session_revoked", "The session has been revoked") - - public class SubjectUnavailable : - SessionError("subject_unavailable", "The subject cannot authenticate") - - public class Conflict : - SessionError( - "session_conflict", "The session changed concurrently; retry with current state") - - public class ReuseDetected : - SessionError("token_reuse", "A superseded session credential was reused") - - public class SessionLimitReached : - SessionError("session_limit", "The maximum active-session count was reached") -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionStore.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionStore.kt deleted file mode 100644 index 771add7..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionStore.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.time.Instant - -/** Parameters for atomically creating and admitting a session. */ -public data class CreateSessionCommand( - public val session: StoredSession, - public val maximumActiveSessions: Int, - public val evictOldestWhenFull: Boolean, -) - -/** Outcome of [SessionStore.create], including any session evicted to preserve the limit. */ -public sealed interface CreateSessionResult { - public data class Created( - public val session: StoredSession, - public val evictedSessionIds: List, - ) : CreateSessionResult - - public data object LimitReached : CreateSessionResult - - public data object SelectorConflict : CreateSessionResult -} - -/** Optimistic compare-and-set command for credential rotation. */ -public data class RotateSessionCommand( - public val sessionId: SessionId, - public val expectedVersion: Long, - public val expectedCurrentDigest: TokenDigest, - public val replacementDigest: TokenDigest, - public val previousValidUntil: Instant, - public val usedAt: Instant, -) - -/** Outcome of [SessionStore.compareAndRotate]. */ -public sealed interface RotateSessionResult { - public data class Rotated(public val session: StoredSession) : RotateSessionResult - - public data object Conflict : RotateSessionResult - - public data object Missing : RotateSessionResult -} - -/** Parameters for idempotently revoking one session. */ -public data class RevokeSessionCommand( - public val sessionId: SessionId, - public val expectedVersion: Long?, - public val revokedAt: Instant, - public val reason: RevocationReason, -) - -/** Outcome of [SessionStore.revoke]. */ -public sealed interface RevokeSessionResult { - public data class Revoked(public val session: StoredSession) : RevokeSessionResult - - public data object AlreadyRevoked : RevokeSessionResult - - public data object Conflict : RevokeSessionResult - - public data object Missing : RevokeSessionResult -} - -/** - * Persistence boundary for immutable session snapshots. - * - * Implementations must make admission, rotation, and revocation atomic and must return detached - * snapshots that callers cannot mutate after commit. - */ -public interface SessionStore { - /** Creates and admits a session atomically with the maximum-session invariant. */ - public fun create(command: CreateSessionCommand): CreateSessionResult - - /** Looks up one immutable committed snapshot by non-secret selector. */ - public fun findBySelector(selector: String): StoredSession? - - /** Looks up one immutable committed snapshot by stable session ID. */ - public fun findById(sessionId: SessionId): StoredSession? - - /** Compares the expected version and digest and commits exactly one successor. */ - public fun compareAndRotate(command: RotateSessionCommand): RotateSessionResult - - /** - * Records activity for the expected active version without moving its timestamp backward. - * - * Returns `true` when the version is authoritative even if its stored activity is already as - * recent as [usedAt], or `false` when the session is missing, revoked, or changed concurrently. - */ - public fun recordUse(sessionId: SessionId, expectedVersion: Long, usedAt: Instant): Boolean - - /** Revokes one stable session identity. */ - public fun revoke(command: RevokeSessionCommand): RevokeSessionResult - - /** Revokes every active session for a subject and returns affected stable IDs. */ - public fun revokeAll( - subject: SubjectRef, - revokedAt: Instant, - reason: RevocationReason - ): List - - /** Lists sessions for [subject] that are active at [at], newest activity first. */ - public fun listActive(subject: SubjectRef, at: Instant): List - - /** Deletes at most [limit] expired or revoked rows and commits that page independently. */ - public fun deleteExpiredPage(before: Instant, limit: Int): Int -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenCodec.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenCodec.kt deleted file mode 100644 index f2b3b94..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenCodec.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.security.SecureRandom -import java.util.Base64 - -/** - * Parsed credential parts. - * - * [selector] may be used for indexed lookup; [verifier] is secret credential material. - */ -public data class DecodedCredential(public val selector: String, public val verifier: String) - -/** Generates, encodes, and parses transport-safe session credentials. */ -public interface TokenCodec { - /** Generates a cryptographically random selector and verifier. */ - public fun generate(): DecodedCredential - - /** Encodes validated credential parts into their transport representation. */ - public fun encode(selector: String, verifier: String): String - - /** Parses a transport representation or throws when it is malformed. */ - public fun decode(encoded: String): DecodedCredential -} - -/** - * Dot-delimited, unpadded Base64URL credential codec. - * - * The default sizes provide a 128-bit selector and a 256-bit verifier. - */ -public class OpaqueTokenCodec -@JvmOverloads -public constructor( - private val secureRandom: SecureRandom = SecureRandom(), - private val selectorBytes: Int = 16, - private val verifierBytes: Int = 32, -) : TokenCodec { - init { - require(selectorBytes >= 16) { "selector must contain at least 128 bits" } - require(verifierBytes >= 32) { "verifier must contain at least 256 bits" } - } - - override fun generate(): DecodedCredential = - DecodedCredential(randomPart(selectorBytes), randomPart(verifierBytes)) - - override fun encode(selector: String, verifier: String): String { - validatePart(selector, "selector") - validatePart(verifier, "verifier") - return "$selector.$verifier" - } - - override fun decode(encoded: String): DecodedCredential { - if (encoded.length > MAX_CREDENTIAL_CHARS) throw SessionError.InvalidCredential() - val separator = encoded.indexOf('.') - if (separator <= 0 || separator != encoded.lastIndexOf('.') || separator == encoded.lastIndex) { - throw SessionError.InvalidCredential() - } - val selector = encoded.substring(0, separator) - val verifier = encoded.substring(separator + 1) - validatePart(selector, "selector") - validatePart(verifier, "verifier") - return DecodedCredential(selector, verifier) - } - - private fun randomPart(size: Int): String { - val bytes = ByteArray(size) - secureRandom.nextBytes(bytes) - return ENCODER.encodeToString(bytes) - } - - private fun validatePart(value: String, label: String) { - if (value.length !in 22..86 || !value.matches(BASE64_URL)) { - throw IllegalArgumentException("$label is not canonical unpadded Base64URL") - } - runCatching { DECODER.decode(value) }.getOrElse { throw SessionError.InvalidCredential() } - } - - public companion object { - /** Smallest encoded credential accepted by this codec. */ - public const val MIN_CREDENTIAL_CHARS: Int = 66 - /** Largest encoded credential accepted, bounding parsing work for untrusted input. */ - public const val MAX_CREDENTIAL_CHARS: Int = 128 - private val BASE64_URL = Regex("[A-Za-z0-9_-]+") - private val ENCODER = Base64.getUrlEncoder().withoutPadding() - private val DECODER = Base64.getUrlDecoder() - } -} diff --git a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenHasher.kt b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenHasher.kt deleted file mode 100644 index 8b9a88d..0000000 --- a/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenHasher.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.Base64 -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -/** Produces and verifies one-way digests of secret credential verifiers. */ -public interface TokenHasher { - /** Digests [verifier] with the current key and records that key's identifier. */ - public fun digest(verifier: String): TokenDigest - - /** Verifies [verifier] against [digest] in constant time when its key is available. */ - public fun matches(verifier: String, digest: TokenDigest): Boolean -} - -/** - * HMAC-SHA-256 token hasher with key identifiers for non-disruptive key rotation. - * - * New digests use [currentKeyId]; verification accepts any supplied key. Key byte arrays are copied - * during construction so later caller mutation cannot change verification behavior. - */ -public class HmacSha256TokenHasher( - private val currentKeyId: String, - keys: Map, -) : TokenHasher { - private val keys: Map = keys.mapValues { (_, key) -> key.copyOf() } - - init { - require(currentKeyId in keys) { "current token-hash key is missing" } - require(this.keys.isNotEmpty()) { "at least one token-hash key is required" } - this.keys.forEach { (id, key) -> - require(id.isNotBlank()) { "token-hash key ID must not be blank" } - require(key.size >= MINIMUM_KEY_BYTES) { - "token-hash key '$id' must contain at least 256 bits" - } - } - } - - override fun digest(verifier: String): TokenDigest = - TokenDigest(currentKeyId, encode(mac(keys.getValue(currentKeyId), verifier))) - - override fun matches(verifier: String, digest: TokenDigest): Boolean { - val key = keys[digest.keyId] ?: return false - val expected = runCatching { DECODER.decode(digest.value) }.getOrNull() ?: return false - return MessageDigest.isEqual(mac(key, verifier), expected) - } - - private fun mac(key: ByteArray, verifier: String): ByteArray = - Mac.getInstance(ALGORITHM).run { - init(SecretKeySpec(key, ALGORITHM)) - doFinal(verifier.toByteArray(StandardCharsets.US_ASCII)) - } - - private fun encode(value: ByteArray): String = ENCODER.encodeToString(value) - - public companion object { - /** Minimum accepted HMAC key size, in bytes. */ - public const val MINIMUM_KEY_BYTES: Int = 32 - private const val ALGORITHM = "HmacSHA256" - private val ENCODER = Base64.getUrlEncoder().withoutPadding() - private val DECODER = Base64.getUrlDecoder() - } -} diff --git a/ogiri-session-core/src/test/java/com/quantipixels/ogiri/session/JavaConsumerTest.java b/ogiri-session-core/src/test/java/com/quantipixels/ogiri/session/JavaConsumerTest.java deleted file mode 100644 index 46c0df1..0000000 --- a/ogiri-session-core/src/test/java/com/quantipixels/ogiri/session/JavaConsumerTest.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.time.Duration; -import java.time.Instant; -import java.util.HashMap; -import org.junit.jupiter.api.Test; - -class JavaConsumerTest { - @Test - void publicFactoriesAreUsableWithoutKotlinImplementationTypes() { - SubjectRef subject = OgiriSessions.subject("users", "opaque-subject", "tenant-a"); - ClientContext client = OgiriSessions.client("browser", "Laptop"); - SessionPolicy policy = - OgiriSessions.policy().lifetime(Duration.ofDays(7)).maximumActiveSessions(3).build(); - - assertEquals("opaque-subject", subject.subjectValue()); - assertEquals("browser", client.getClientId()); - assertEquals(3, policy.getMaximumActiveSessions()); - } - - @Test - void sessionIdentifiersAreOrdinaryJavaValues() { - SessionId first = new SessionId("session-1"); - SessionId second = new SessionId("session-1"); - HashMap values = new HashMap<>(); - values.put(first, "value"); - - assertEquals("session-1", first.getValue()); - assertEquals("session-1", first.toString()); - assertEquals(first, second); - assertEquals("value", values.get(second)); - } - - private static void compileJavaSessionApi( - SessionStore store, - SessionId sessionId, - StoredSession stored, - SessionCredential credential, - AuthenticatedSession authenticated) { - store.findById(sessionId); - store.recordUse(sessionId, 0, Instant.EPOCH); - stored.getId(); - credential.getSessionId(); - authenticated.getSessionId(); - } -} diff --git a/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionManagerTest.kt b/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionManagerTest.kt deleted file mode 100644 index fcbf766..0000000 --- a/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionManagerTest.kt +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset -import java.util.concurrent.Callable -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue - -class SessionManagerTest { - private val clock = MutableClock(Instant.parse("2026-07-10T10:00:00Z")) - private val store = InMemorySessionStore() - private val allowed = AtomicBoolean(true) - private val manager = - SessionManager( - store, - OpaqueTokenCodec(), - HmacSha256TokenHasher("2026-01", mapOf("2026-01" to ByteArray(32) { 7 })), - SubjectStatusChecker { allowed.get() }, - clock, - SessionPolicy(previousVersionGrace = Duration.ofSeconds(10)), - ) - private val subject = SubjectRef(Realm("users"), SubjectId("user-42")) - private val client = ClientContext("browser-1") - - @Test - fun `previous credential has one immutable deadline despite repeated use`() { - val initial = manager.issue(subject, client) - val rotated = manager.rotate(initial.credential.encoded(OpaqueTokenCodec())) - val fixedDeadline = rotated.session.previousValidUntil - - repeat(4) { - clock.advance(Duration.ofSeconds(2)) - val accepted = manager.authenticate(initial.credential.encoded(OpaqueTokenCodec())) - assertTrue(accepted.usedPreviousVersion) - assertEquals(fixedDeadline, store.findById(initial.session.id)?.previousValidUntil) - } - - clock.set(fixedDeadline!!) - assertFailsWith { - manager.authenticate(initial.credential.encoded(OpaqueTokenCodec())) - } - assertEquals(RevocationReason.TOKEN_REUSE, store.findById(initial.session.id)?.revocationReason) - } - - @Test - fun `three rotations never retain an unbounded historical credential`() { - val t0 = manager.issue(subject, client) - val t1 = manager.rotate(t0.credential.encoded(OpaqueTokenCodec())) - val t2 = manager.rotate(t1.credential.encoded(OpaqueTokenCodec())) - manager.rotate(t2.credential.encoded(OpaqueTokenCodec())) - - assertFailsWith { - manager.authenticate(t0.credential.encoded(OpaqueTokenCodec())) - } - val stored = store.findById(t0.session.id)!! - assertEquals(3, stored.version) - assertTrue(stored.previousDigest != null) - assertTrue(stored.previousValidUntil != null) - } - - @Test - fun `parallel rotation commits exactly one successor`() { - val issued = manager.issue(subject, client) - val credential = issued.credential.encoded(OpaqueTokenCodec()) - val executor = Executors.newFixedThreadPool(12) - val outcomes = - executor.invokeAll( - List(50) { - Callable { - runCatching { manager.rotate(credential) } - .fold({ "rotated" }, { it::class.simpleName!! }) - } - }) - executor.shutdown() - - val values = outcomes.map { it.get() } - assertEquals(1, values.count { it == "rotated" }) - assertEquals(49, values.count { it == "Conflict" }) - assertEquals(1, store.findById(issued.session.id)?.version) - } - - @Test - fun `disabled subject is denied and active session is revoked`() { - val issued = manager.issue(subject, client) - allowed.set(false) - - assertFailsWith { - manager.authenticate(issued.credential.encoded(OpaqueTokenCodec())) - } - assertEquals( - RevocationReason.ACCOUNT_DISABLED, store.findById(issued.session.id)?.revocationReason) - } - - @Test - fun `revoke uses stable session identity and is idempotent`() { - val issued = manager.issue(subject, client) - val authenticated = manager.authenticate(issued.credential.encoded(OpaqueTokenCodec())) - - assertTrue(manager.revoke(authenticated)) - assertFalse(manager.revoke(authenticated)) - assertFailsWith { - manager.authenticate(issued.credential.encoded(OpaqueTokenCodec())) - } - } - - @Test - fun `maximum session admission is atomic and counts sessions only`() { - val limited = - SessionManager( - store, - OpaqueTokenCodec(), - HmacSha256TokenHasher("k", mapOf("k" to ByteArray(32) { 1 })), - SubjectStatusChecker { true }, - clock, - SessionPolicy(maximumActiveSessions = 2, evictOldestWhenFull = false), - ) - val executor = Executors.newFixedThreadPool(10) - val results = - executor.invokeAll( - List(20) { - Callable { - runCatching { limited.issue(subject, ClientContext("client-$it")) }.isSuccess - } - }) - executor.shutdown() - - assertEquals(2, results.count { it.get() }) - assertEquals(2, limited.list(subject).size) - } - - @Test - fun `authentication fails when concurrent revocation wins final state check`() { - val events = mutableListOf() - val raceStore = - object : SessionStore by store { - override fun recordUse( - sessionId: SessionId, - expectedVersion: Long, - usedAt: Instant, - ): Boolean { - store.revoke( - RevokeSessionCommand( - sessionId, - expectedVersion, - usedAt, - RevocationReason.ADMINISTRATIVE, - )) - return false - } - } - val codec = OpaqueTokenCodec() - val raceManager = - SessionManager( - raceStore, - codec, - HmacSha256TokenHasher("race", mapOf("race" to ByteArray(32) { 9 })), - SubjectStatusChecker { true }, - clock, - events = SessionEventPublisher(events::add), - ) - val issued = raceManager.issue(subject, ClientContext("race-browser")) - events.clear() - - assertFailsWith { - raceManager.authenticate(issued.credential.encoded(codec)) - } - assertTrue(events.none { it.action == SessionEventAction.AUTHENTICATED }) - assertEquals( - RevocationReason.ADMINISTRATIVE, store.findById(issued.session.id)?.revocationReason) - } - - @Test - fun `issued plaintext is separate from immutable stored session`() { - val issued = manager.issue(subject, client) - val stored = store.findById(issued.session.id)!! - - assertNotEquals(issued.credential.verifier, stored.currentDigest.value) - assertFalse(stored.toString().contains(issued.credential.verifier)) - } -} - -private class MutableClock(private var current: Instant) : Clock() { - override fun getZone(): ZoneId = ZoneOffset.UTC - - override fun withZone(zone: ZoneId): Clock = this - - override fun instant(): Instant = synchronized(this) { current } - - fun advance(duration: Duration) = synchronized(this) { current = current.plus(duration) } - - fun set(instant: Instant) = synchronized(this) { current = instant } -} - -private class InMemorySessionStore : SessionStore { - private val byId = ConcurrentHashMap() - private val selectorToId = ConcurrentHashMap() - - @Synchronized - override fun create(command: CreateSessionCommand): CreateSessionResult { - if (selectorToId.containsKey(command.session.selector)) - return CreateSessionResult.SelectorConflict - val active = - byId.values - .filter { - it.subject == command.session.subject && it.isActive(command.session.createdAt) - } - .sortedBy { it.createdAt } - val evicted = mutableListOf() - if (active.size >= command.maximumActiveSessions) { - if (!command.evictOldestWhenFull) return CreateSessionResult.LimitReached - active.take(active.size - command.maximumActiveSessions + 1).forEach { - byId[it.id] = - it.copy( - version = it.version + 1, - revokedAt = command.session.createdAt, - revocationReason = RevocationReason.SESSION_LIMIT, - ) - selectorToId.remove(it.selector) - evicted += it.id - } - } - byId[command.session.id] = command.session.copy() - selectorToId[command.session.selector] = command.session.id - return CreateSessionResult.Created(command.session.copy(), evicted) - } - - override fun findBySelector(selector: String): StoredSession? = - selectorToId[selector]?.let(byId::get)?.copy() - - override fun findById(sessionId: SessionId): StoredSession? = byId[sessionId]?.copy() - - @Synchronized - override fun compareAndRotate(command: RotateSessionCommand): RotateSessionResult { - val current = byId[command.sessionId] ?: return RotateSessionResult.Missing - if (current.version != command.expectedVersion || - current.currentDigest != command.expectedCurrentDigest || - current.revokedAt != null) { - return RotateSessionResult.Conflict - } - val rotated = - current.copy( - currentDigest = command.replacementDigest, - previousDigest = current.currentDigest, - previousValidUntil = command.previousValidUntil, - version = current.version + 1, - lastUsedAt = command.usedAt, - ) - byId[current.id] = rotated - return RotateSessionResult.Rotated(rotated.copy()) - } - - @Synchronized - override fun recordUse(sessionId: SessionId, expectedVersion: Long, usedAt: Instant): Boolean { - val current = byId[sessionId] ?: return false - if (current.version != expectedVersion || current.revokedAt != null) return false - byId[sessionId] = current.copy(lastUsedAt = maxOf(current.lastUsedAt, usedAt)) - return true - } - - @Synchronized - override fun revoke(command: RevokeSessionCommand): RevokeSessionResult { - val current = byId[command.sessionId] ?: return RevokeSessionResult.Missing - if (current.revokedAt != null) return RevokeSessionResult.AlreadyRevoked - if (command.expectedVersion != null && current.version != command.expectedVersion) { - return RevokeSessionResult.Conflict - } - val revoked = - current.copy( - version = current.version + 1, - revokedAt = command.revokedAt, - revocationReason = command.reason, - ) - byId[current.id] = revoked - return RevokeSessionResult.Revoked(revoked.copy()) - } - - @Synchronized - override fun revokeAll( - subject: SubjectRef, - revokedAt: Instant, - reason: RevocationReason, - ): List = - byId.values - .filter { it.subject == subject && it.revokedAt == null } - .map { - byId[it.id] = - it.copy(version = it.version + 1, revokedAt = revokedAt, revocationReason = reason) - it.id - } - - override fun listActive(subject: SubjectRef, at: Instant): List = - byId.values.filter { it.subject == subject && it.isActive(at) }.map { it.copy() } - - @Synchronized - override fun deleteExpiredPage(before: Instant, limit: Int): Int { - val ids = - byId.values - .filter { !it.expiresAt.isAfter(before) || it.revokedAt?.isAfter(before) == false } - .sortedBy { it.id.value } - .take(limit) - .map { it.id } - ids.forEach { id -> byId.remove(id)?.let { selectorToId.remove(it.selector) } } - return ids.size - } -} diff --git a/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionModelTest.kt b/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionModelTest.kt deleted file mode 100644 index 3a0d76b..0000000 --- a/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionModelTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.session - -import java.time.Instant -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class SessionModelTest { - @Test - fun `session identifiers have value equality`() { - val first = SessionId("session-1") - val second = SessionId("session-1") - - assertEquals(first, second) - assertEquals(first.hashCode(), second.hashCode()) - assertEquals("value", mapOf(first to "value")[second]) - } - - @Test - fun `credential string representations redact transport material`() { - val credential = - SessionCredential(SessionId("session-1"), "distinct-selector", "distinct-verifier") - val issued = - IssuedSession( - StoredSession( - id = credential.sessionId, - selector = credential.selector, - subject = SubjectRef(Realm("users"), SubjectId("subject")), - client = ClientContext("browser"), - currentDigest = TokenDigest("key", "digest"), - previousDigest = null, - previousValidUntil = null, - version = 0, - familyId = "family", - createdAt = Instant.EPOCH, - lastUsedAt = Instant.EPOCH, - expiresAt = Instant.EPOCH.plusSeconds(60), - ), - credential, - ) - - assertFalse(credential.toString().contains(credential.selector)) - assertFalse(credential.toString().contains(credential.verifier)) - assertFalse(issued.toString().contains(credential.selector)) - assertFalse(issued.toString().contains(credential.verifier)) - assertTrue(credential.toString().contains("[REDACTED]")) - } -} diff --git a/ogiri-spring-security/pom.xml b/ogiri-spring-security/pom.xml new file mode 100644 index 0000000..7b2e5ad --- /dev/null +++ b/ogiri-spring-security/pom.xml @@ -0,0 +1,12 @@ + + 4.0.0 + com.quantipixels.ogiriogiri-parent0.1.0 + ogiri-spring-security + Ogiri Spring Security adapter + + com.quantipixels.ogiriogiri${project.version} + org.springframework.securityspring-security-oauth2-resource-server + org.junit.jupiterjunit-jupitertest + org.postgresqlpostgresqltest + + diff --git a/ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java b/ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java new file mode 100644 index 0000000..ae10d26 --- /dev/null +++ b/ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri.spring; + +import com.quantipixels.ogiri.PostgresSessions; +import com.quantipixels.ogiri.Session; +import com.quantipixels.ogiri.SessionStoreException; +import com.quantipixels.ogiri.Subject; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import org.springframework.security.authentication.AccountStatusException; +import org.springframework.security.authentication.AccountStatusUserDetailsChecker; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; + +/** + * Connects Ogiri to Spring Security's native opaque-token resource server. The application supplies + * a full-identity account loader and owns its security chain, login endpoints and CSRF policy. + * Account state and authorities are loaded on every request, after credential verification. + * The returned principal contains metadata, never the credential or stored digest. + */ +public final class OgiriOpaqueTokenIntrospector implements OpaqueTokenIntrospector { + private final PostgresSessions sessions; + private final Function accounts; + private final AccountStatusUserDetailsChecker status = new AccountStatusUserDetailsChecker(); + + public OgiriOpaqueTokenIntrospector(PostgresSessions sessions, Function accounts) { + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.accounts = Objects.requireNonNull(accounts, "accounts"); + } + + @Override public OAuth2AuthenticatedPrincipal introspect(String token) { + final Session session; + try { session = sessions.authenticate(token).orElseThrow(() -> new BadOpaqueTokenException("Invalid session")); } + catch (SessionStoreException failure) { throw new OAuth2IntrospectionException("Session store unavailable", failure); } + final UserDetails account; + try { + account = accounts.apply(session.subject()); + if (account == null) throw new UsernameNotFoundException("Unknown account"); + status.check(account); + } catch (UsernameNotFoundException | AccountStatusException unavailable) { + throw new BadOpaqueTokenException("Invalid session"); + } catch (RuntimeException failure) { + throw new OAuth2IntrospectionException("Account store unavailable", failure); + } + return new DefaultOAuth2AuthenticatedPrincipal(session.subject().subjectId(), Map.of( + "sub", session.subject().subjectId(), + "realm", session.subject().realm(), + "tenant_id", session.subject().tenantId(), + "session_id", session.id().toString(), + "client", session.client()), java.util.List.copyOf(account.getAuthorities())); + } +} diff --git a/ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java b/ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java new file mode 100644 index 0000000..8f2465f --- /dev/null +++ b/ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.quantipixels.ogiri.spring; + +import static org.junit.jupiter.api.Assertions.*; +import com.quantipixels.ogiri.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.*; +import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException; +import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; + +class OgiriOpaqueTokenIntrospectorTest { + private static PGSimpleDataSource dataSource; + private PostgresSessions sessions; + private static final Subject OWNER = new Subject("users", "tenant-a", "stable-42"); + + @BeforeAll static void database() throws Exception { + dataSource = new PGSimpleDataSource(); + dataSource.setURL(java.util.Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL"), "Disposable PostgreSQL required")); + dataSource.setUser(System.getenv("OGIRI_TEST_JDBC_USER")); + dataSource.setPassword(System.getenv("OGIRI_TEST_JDBC_PASSWORD")); + try (var connection = dataSource.getConnection(); var statement = connection.createStatement(); + var schema = PostgresSessions.class.getResourceAsStream("/META-INF/ogiri/schema-postgresql.sql")) { + statement.execute("DROP TABLE IF EXISTS ogiri_sessions"); + statement.execute(new String(java.util.Objects.requireNonNull(schema).readAllBytes(), StandardCharsets.UTF_8)); + } + } + + @BeforeEach void reset() throws Exception { + try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { statement.execute("TRUNCATE ogiri_sessions"); } + sessions = new PostgresSessions(dataSource); + } + + @Test void principalCarriesStableScopedIdentityAndCurrentAuthoritiesButNoSecret() { + var issued = sessions.issue(OWNER, "browser"); + var seen = new AtomicReference(); + var adapter = new OgiriOpaqueTokenIntrospector(sessions, subject -> { + seen.set(subject); + return User.withUsername("renamable-login").password("unused").roles("ADMIN").build(); + }); + var principal = adapter.introspect(issued.token()); + assertEquals(OWNER, seen.get()); + assertEquals("stable-42", principal.getName()); + assertEquals("tenant-a", principal.getAttribute("tenant_id")); + assertEquals("users", principal.getAttribute("realm")); + assertEquals(issued.session().id().toString(), principal.getAttribute("session_id")); + assertTrue(principal.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))); + assertFalse(principal.getAttributes().toString().contains(issued.token())); + assertFalse(principal.getAttributes().containsKey("token_hash")); + } + + @Test void disabledLockedExpiredAndMissingAccountsFailClosedWithoutCachingState() { + var token = sessions.issue(OWNER, "phone").token(); + var current = new AtomicReference(User.withUsername("login").password("unused").roles("USER").build()); + var adapter = new OgiriOpaqueTokenIntrospector(sessions, subject -> current.get()); + assertNotNull(adapter.introspect(token)); + for (UserDetails disallowed : java.util.List.of( + User.withUsername("login").password("unused").roles("USER").disabled(true).build(), + User.withUsername("login").password("unused").roles("USER").accountLocked(true).build(), + User.withUsername("login").password("unused").roles("USER").accountExpired(true).build(), + User.withUsername("login").password("unused").roles("USER").credentialsExpired(true).build())) { + current.set(disallowed); + assertThrows(BadOpaqueTokenException.class, () -> adapter.introspect(token)); + } + current.set(null); + assertThrows(BadOpaqueTokenException.class, () -> adapter.introspect(token)); + current.set(User.withUsername("new-login").password("unused").roles("ADMIN").build()); + assertTrue(adapter.introspect(token).getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))); + } + + @Test void invalidCredentialsDoNotQueryAccountsAndOutagesAreNotInvalidTokenErrors() { + var calls = new AtomicInteger(); + var adapter = new OgiriOpaqueTokenIntrospector(sessions, subject -> { calls.incrementAndGet(); throw new IllegalStateException("directory offline"); }); + assertThrows(BadOpaqueTokenException.class, () -> adapter.introspect("bad")); + assertEquals(0, calls.get()); + var token = sessions.issue(OWNER, "phone").token(); + var outage = assertThrows(OAuth2IntrospectionException.class, () -> adapter.introspect(token)); + assertFalse(outage instanceof BadOpaqueTokenException); + assertEquals(1, calls.get()); + assertInstanceOf(IllegalStateException.class, outage.getCause()); + } +} diff --git a/ogiri-test/build.gradle.kts b/ogiri-test/build.gradle.kts deleted file mode 100644 index 557d3fb..0000000 --- a/ogiri-test/build.gradle.kts +++ /dev/null @@ -1,85 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - `java-library` - `maven-publish` - signing -} - -group = "com.quantipixels.ogiri" - -java { - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } - withSourcesJar() - withJavadocJar() -} - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } - jvmToolchain(17) -} - -dependencies { - api(project(":ogiri-session-core")) - api("org.springframework:spring-test:6.2.12") - api("org.springframework:spring-web:6.2.12") - api("jakarta.servlet:jakarta.servlet-api:6.1.0") - testImplementation(kotlin("test")) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { useJUnitPlatform() } - -publishing { - publications { - create("mavenJava") { - from(components["java"]) - pom { - name.set("ogiri-test") - description.set("Reusable test fixtures for Ogiri session-store integrations.") - url.set("https://github.com/quantipixels/ogiri") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0") - } - } - developers { - developer { - id.set("quantipixels") - name.set("Olúwaṣèyí Ṣóbandé") - email.set("oluwaseyi@quantipixels.com") - } - } - scm { - url.set("https://github.com/quantipixels/ogiri") - connection.set("scm:git:https://github.com/quantipixels/ogiri.git") - developerConnection.set("scm:git:ssh://git@github.com/quantipixels/ogiri.git") - } - } - } - } - repositories { - maven { - name = "CentralPortal" - val releasesUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsUrl = uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl - credentials { - username = (findProperty("ossrhUsername") ?: System.getenv("OSSRH_USERNAME"))?.toString() - password = (findProperty("ossrhPassword") ?: System.getenv("OSSRH_PASSWORD"))?.toString() - } - } - } -} - -signing { - val key = (findProperty("signing.key") ?: System.getenv("GPG_PRIVATE_KEY"))?.toString() - val password = (findProperty("signing.password") ?: System.getenv("GPG_PASSPHRASE"))?.toString() - if (key != null && password != null) { - useInMemoryPgpKeys(key, password) - sign(publishing.publications["mavenJava"]) - } -} diff --git a/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/InMemorySessionStore.kt b/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/InMemorySessionStore.kt deleted file mode 100644 index 5f062d1..0000000 --- a/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/InMemorySessionStore.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.test - -import com.quantipixels.ogiri.session.CreateSessionCommand -import com.quantipixels.ogiri.session.CreateSessionResult -import com.quantipixels.ogiri.session.RevocationReason -import com.quantipixels.ogiri.session.RevokeSessionCommand -import com.quantipixels.ogiri.session.RevokeSessionResult -import com.quantipixels.ogiri.session.RotateSessionCommand -import com.quantipixels.ogiri.session.RotateSessionResult -import com.quantipixels.ogiri.session.SessionId -import com.quantipixels.ogiri.session.SessionStore -import com.quantipixels.ogiri.session.StoredSession -import com.quantipixels.ogiri.session.SubjectRef -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap - -/** - * Thread-safe in-memory [SessionStore] for tests. - * - * It implements the same admission and optimistic-update outcomes as production stores and returns - * copies so test code cannot mutate committed state accidentally. - */ -public class InMemorySessionStore : SessionStore { - private val byId = ConcurrentHashMap() - private val selectorToId = ConcurrentHashMap() - - @Synchronized - override fun create(command: CreateSessionCommand): CreateSessionResult { - if (selectorToId.containsKey(command.session.selector)) - return CreateSessionResult.SelectorConflict - val active = - byId.values - .filter { - it.subject == command.session.subject && it.isActive(command.session.createdAt) - } - .sortedWith(compareBy(StoredSession::createdAt, { it.id.value })) - val evicted = mutableListOf() - if (active.size >= command.maximumActiveSessions) { - if (!command.evictOldestWhenFull) return CreateSessionResult.LimitReached - active.take(active.size - command.maximumActiveSessions + 1).forEach { - byId[it.id] = - it.copy( - version = it.version + 1, - revokedAt = command.session.createdAt, - revocationReason = RevocationReason.SESSION_LIMIT, - ) - evicted += it.id - } - } - byId[command.session.id] = command.session.copy() - selectorToId[command.session.selector] = command.session.id - return CreateSessionResult.Created(command.session.copy(), evicted) - } - - override fun findBySelector(selector: String): StoredSession? = - selectorToId[selector]?.let(byId::get)?.copy() - - override fun findById(sessionId: SessionId): StoredSession? = byId[sessionId]?.copy() - - @Synchronized - override fun compareAndRotate(command: RotateSessionCommand): RotateSessionResult { - val current = byId[command.sessionId] ?: return RotateSessionResult.Missing - if (current.version != command.expectedVersion || - current.currentDigest != command.expectedCurrentDigest || - current.revokedAt != null) { - return RotateSessionResult.Conflict - } - val rotated = - current.copy( - currentDigest = command.replacementDigest, - previousDigest = current.currentDigest, - previousValidUntil = command.previousValidUntil, - version = current.version + 1, - lastUsedAt = command.usedAt, - ) - byId[current.id] = rotated - return RotateSessionResult.Rotated(rotated.copy()) - } - - @Synchronized - override fun recordUse(sessionId: SessionId, expectedVersion: Long, usedAt: Instant): Boolean { - val current = byId[sessionId] ?: return false - if (current.version != expectedVersion || current.revokedAt != null) return false - byId[sessionId] = current.copy(lastUsedAt = maxOf(current.lastUsedAt, usedAt)) - return true - } - - @Synchronized - override fun revoke(command: RevokeSessionCommand): RevokeSessionResult { - val current = byId[command.sessionId] ?: return RevokeSessionResult.Missing - if (current.revokedAt != null) return RevokeSessionResult.AlreadyRevoked - if (command.expectedVersion != null && current.version != command.expectedVersion) { - return RevokeSessionResult.Conflict - } - val revoked = - current.copy( - version = current.version + 1, - revokedAt = command.revokedAt, - revocationReason = command.reason, - ) - byId[current.id] = revoked - return RevokeSessionResult.Revoked(revoked.copy()) - } - - @Synchronized - override fun revokeAll( - subject: SubjectRef, - revokedAt: Instant, - reason: RevocationReason, - ): List = - byId.values - .filter { it.subject == subject && it.revokedAt == null } - .map { - byId[it.id] = - it.copy(version = it.version + 1, revokedAt = revokedAt, revocationReason = reason) - it.id - } - - override fun listActive(subject: SubjectRef, at: Instant): List = - byId.values - .filter { it.subject == subject && it.isActive(at) } - .sortedWith(compareByDescending { it.lastUsedAt }.thenBy { it.id.value }) - .map { it.copy() } - - @Synchronized - override fun deleteExpiredPage(before: Instant, limit: Int): Int { - val ids = - byId.values - .filter { !it.expiresAt.isAfter(before) || it.revokedAt?.isAfter(before) == false } - .sortedBy { it.id.value } - .take(limit) - .map { it.id } - ids.forEach { id -> byId.remove(id)?.let { selectorToId.remove(it.selector) } } - return ids.size - } - - /** Returns detached copies of every stored session, including revoked and expired entries. */ - public fun snapshot(): List = byId.values.map(StoredSession::copy) -} diff --git a/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/OgiriTestSupport.kt b/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/OgiriTestSupport.kt deleted file mode 100644 index d1c4838..0000000 --- a/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/OgiriTestSupport.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.test - -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneId -import java.time.ZoneOffset -import org.springframework.http.HttpHeaders -import org.springframework.test.web.servlet.request.RequestPostProcessor - -/** Thread-safe, UTC [Clock] whose instant can be advanced deterministically in tests. */ -public class OgiriFakeClock -@JvmOverloads -public constructor(private var current: Instant = Instant.parse("2026-01-01T00:00:00Z")) : Clock() { - override fun getZone(): ZoneId = ZoneOffset.UTC - - override fun withZone(zone: ZoneId): Clock = this - - override fun instant(): Instant = synchronized(this) { current } - - /** Advances the clock by [duration] and returns the resulting instant. */ - public fun advance(duration: Duration): Instant = - synchronized(this) { - current = current.plus(duration) - current - } - - /** Replaces the current instant. */ - public fun set(instant: Instant): Unit = synchronized(this) { current = instant } -} - -/** Java-friendly MockMvc request processors for Ogiri credential transports. */ -public object OgiriMockMvc { - @JvmStatic - /** Adds a Bearer authorization header carrying [credential]. */ - public fun bearer(credential: String): RequestPostProcessor = RequestPostProcessor { request -> - request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer $credential") - request - } - - @JvmStatic - /** Adds legacy DTA-compatible access-token, client, and uid headers. */ - public fun dta(credential: String, client: String, uid: String): RequestPostProcessor = - RequestPostProcessor { request -> - request.addHeader("access-token", credential) - request.addHeader("client", client) - request.addHeader("uid", uid) - request - } -} diff --git a/ogiri/pom.xml b/ogiri/pom.xml new file mode 100644 index 0000000..c31b43a --- /dev/null +++ b/ogiri/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.quantipixels.ogiriogiri-parent0.1.0 + ogiri + Ogiri PostgreSQL sessions + + org.junit.jupiterjunit-jupitertest + org.postgresqlpostgresqltest + + diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/IssuedSession.java b/ogiri/src/main/java/com/quantipixels/ogiri/IssuedSession.java new file mode 100644 index 0000000..51d698a --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/IssuedSession.java @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +/** An issuance result. Explicitly deliver token() once over TLS; never serialize or log this object. */ +public final class IssuedSession { + private final Session session; + private final String token; + + IssuedSession(Session session, String token) { + this.session = session; + this.token = token; + } + + public Session session() { return session; } + public String token() { return token; } + + @Override public String toString() { return "IssuedSession[id=" + session.id() + ", token=]"; } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java b/ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java new file mode 100644 index 0000000..d57643e --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import javax.sql.DataSource; + +/** + * Thread-safe PostgreSQL opaque-session lifecycle. Supply an ordinary pool, not a transaction-bound + * proxy. Mutations use independent committed transactions. Authentication is a read-only lookup. + * Pool acquisition/network timeouts remain the application's responsibility; each SQL statement + * has a five-second query timeout. Schema creation and scheduling are explicitly application-owned. + */ +public final class PostgresSessions { + private static final String COLUMNS = "id, realm, tenant_id, subject_id, client, created_at, expires_at"; + private static final String OWNER = "realm = ? AND tenant_id = ? AND subject_id = ?"; + private final DataSource dataSource; + private final SessionPolicy policy; + + public PostgresSessions(DataSource dataSource) { this(dataSource, SessionPolicy.defaults()); } + + public PostgresSessions(DataSource dataSource, SessionPolicy policy) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** Issue only after the application authenticates and authorizes the complete Subject. */ + public IssuedSession issue(Subject subject, String client) { + Objects.requireNonNull(subject, "subject"); + if (client == null || client.isBlank() || client.length() > 255 || client.indexOf(0) >= 0) + throw new IllegalArgumentException("client must contain 1 to 255 characters"); + String token = Tokens.generate(); + return write(connection -> { + lock(connection, subject); + Instant now; + try (PreparedStatement statement = prepare(connection, "SELECT statement_timestamp()")) { + try (ResultSet rows = statement.executeQuery()) { rows.next(); now = rows.getObject(1, OffsetDateTime.class).toInstant(); } + } + try (PreparedStatement statement = prepare(connection, + "SELECT count(*) FROM ogiri_sessions WHERE " + OWNER + " AND expires_at > ?")) { + owner(statement, subject); instant(statement, 4, now); + try (ResultSet rows = statement.executeQuery()) { + rows.next(); + if (rows.getInt(1) >= policy.maximumSessions()) throw new SessionLimitException(); + } + } + Session session = new Session(UUID.randomUUID(), subject, client, now, now.plusMillis(policy.lifetime().toMillis())); + try (PreparedStatement statement = prepare(connection, + "INSERT INTO ogiri_sessions (" + COLUMNS + ", token_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { + statement.setObject(1, session.id()); + statement.setString(2, subject.realm()); statement.setString(3, subject.tenantId()); statement.setString(4, subject.subjectId()); + statement.setString(5, client); instant(statement, 6, now); instant(statement, 7, session.expiresAt()); + statement.setBytes(8, Tokens.digest(token)); statement.executeUpdate(); + } + return new IssuedSession(session, token); + }); + } + + /** Find a valid credential at the database statement's start; malformed, expired and revoked tokens return empty. */ + public Optional authenticate(String token) { + byte[] digest = Tokens.digest(token); + if (digest == null) return Optional.empty(); + try (Connection connection = connection(); + PreparedStatement statement = prepare(connection, + "SELECT " + COLUMNS + " FROM ogiri_sessions WHERE token_hash = ? AND expires_at > statement_timestamp()")) { + statement.setBytes(1, digest); + try (ResultSet rows = statement.executeQuery()) { return rows.next() ? Optional.of(session(rows)) : Optional.empty(); } + } catch (SQLException failure) { throw new SessionStoreException(failure); } + } + + /** List live metadata for an application-authorized owner, in creation order. Never use a request-supplied owner unchecked. */ + public List list(Subject subject) { + Objects.requireNonNull(subject, "subject"); + try (Connection connection = connection(); + PreparedStatement statement = prepare(connection, + "SELECT " + COLUMNS + " FROM ogiri_sessions WHERE " + OWNER + " AND expires_at > statement_timestamp() ORDER BY created_at, id")) { + owner(statement, subject); + try (ResultSet rows = statement.executeQuery()) { + List sessions = new ArrayList<>(); + while (rows.next()) sessions.add(session(rows)); + return List.copyOf(sessions); + } + } catch (SQLException failure) { throw new SessionStoreException(failure); } + } + + /** Revoke one owned session. A foreign or already absent identifier returns false. */ + public boolean revoke(Subject subject, UUID sessionId) { + Objects.requireNonNull(subject, "subject"); Objects.requireNonNull(sessionId, "sessionId"); + return write(connection -> { + try (PreparedStatement statement = prepare(connection, "DELETE FROM ogiri_sessions WHERE " + OWNER + " AND id = ?")) { + owner(statement, subject); statement.setObject(4, sessionId); + return statement.executeUpdate() == 1; + } + }); + } + + /** Serialize with issuance and remove all this owner's sessions. Later sign-ins remain possible. */ + public int revokeAll(Subject subject) { + Objects.requireNonNull(subject, "subject"); + return write(connection -> { + lock(connection, subject); + try (PreparedStatement statement = prepare(connection, "DELETE FROM ogiri_sessions WHERE " + OWNER)) { + owner(statement, subject); return statement.executeUpdate(); + } + }); + } + + /** Delete at most batchSize expired rows. Concurrent workers skip locked rows; no leader election is needed. */ + public int cleanup(int batchSize) { + if (batchSize < 1 || batchSize > 10_000) throw new IllegalArgumentException("batchSize must be between 1 and 10000"); + return write(connection -> { + try (PreparedStatement statement = prepare(connection, """ + WITH expired AS ( + SELECT id FROM ogiri_sessions WHERE expires_at <= statement_timestamp() + ORDER BY expires_at, id LIMIT ? FOR UPDATE SKIP LOCKED + ) + DELETE FROM ogiri_sessions AS sessions USING expired WHERE sessions.id = expired.id + """)) { + statement.setInt(1, batchSize); return statement.executeUpdate(); + } + }); + } + + private T write(SqlWork work) { + try (Connection connection = connection()) { + connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + connection.setAutoCommit(false); + try { + T result = work.apply(connection); + connection.commit(); + return result; + } catch (SQLException | RuntimeException | Error failure) { + try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); } + throw failure; + } + } catch (SQLException failure) { throw new SessionStoreException(failure); } + } + + private Connection connection() throws SQLException { + Connection connection = dataSource.getConnection(); + try { + if (!connection.getAutoCommit()) throw new SQLException("DataSource must supply auto-commit connections"); + return connection; + } catch (SQLException failure) { + try { connection.close(); } catch (SQLException close) { failure.addSuppressed(close); } + throw failure; + } + } + + private static PreparedStatement prepare(Connection connection, String sql) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + try { statement.setQueryTimeout(5); return statement; } + catch (SQLException failure) { statement.close(); throw failure; } + } + + private static void lock(Connection connection, Subject subject) throws SQLException { + try (PreparedStatement statement = prepare(connection, "SELECT pg_advisory_xact_lock(?)")) { + statement.setLong(1, Tokens.lockKey(subject)); statement.execute(); + } + } + + private static void owner(PreparedStatement statement, Subject subject) throws SQLException { + statement.setString(1, subject.realm()); statement.setString(2, subject.tenantId()); statement.setString(3, subject.subjectId()); + } + + private static void instant(PreparedStatement statement, int index, Instant time) throws SQLException { + statement.setObject(index, time.atOffset(ZoneOffset.UTC)); + } + + private static Session session(ResultSet rows) throws SQLException { + return new Session(rows.getObject("id", UUID.class), + new Subject(rows.getString("realm"), rows.getString("tenant_id"), rows.getString("subject_id")), + rows.getString("client"), rows.getObject("created_at", OffsetDateTime.class).toInstant(), + rows.getObject("expires_at", OffsetDateTime.class).toInstant()); + } + + @FunctionalInterface private interface SqlWork { T apply(Connection connection) throws SQLException; } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Session.java b/ogiri/src/main/java/com/quantipixels/ogiri/Session.java new file mode 100644 index 0000000..05b6c69 --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Session.java @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +import java.time.Instant; +import java.util.UUID; + +/** Public session metadata. The identifier is for management; it is not an authentication credential. */ +public record Session(UUID id, Subject subject, String client, Instant createdAt, Instant expiresAt) {} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/SessionLimitException.java b/ogiri/src/main/java/com/quantipixels/ogiri/SessionLimitException.java new file mode 100644 index 0000000..3189d8d --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/SessionLimitException.java @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +/** Issuance was refused without evicting an existing session. Let the account owner revoke a device. */ +public final class SessionLimitException extends RuntimeException { + public SessionLimitException() { super("Active session limit reached"); } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/SessionPolicy.java b/ogiri/src/main/java/com/quantipixels/ogiri/SessionPolicy.java new file mode 100644 index 0000000..0bfae26 --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/SessionPolicy.java @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +import java.time.Duration; +import java.util.Objects; + +/** Fixed lifetime and admission limit. All application instances must use the same policy. */ +public record SessionPolicy(Duration lifetime, int maximumSessions) { + public SessionPolicy { + Objects.requireNonNull(lifetime, "lifetime"); + if (lifetime.compareTo(Duration.ofSeconds(1)) < 0 || lifetime.compareTo(Duration.ofDays(365)) > 0) + throw new IllegalArgumentException("lifetime must be between one second and 365 days"); + if (maximumSessions < 1 || maximumSessions > 1000) + throw new IllegalArgumentException("maximumSessions must be between 1 and 1000"); + } + + public static SessionPolicy defaults() { return new SessionPolicy(Duration.ofDays(7), 10); } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java b/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java new file mode 100644 index 0000000..c6bf8d3 --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +/** Storage is unavailable or rejected an operation. Never translate this into a successful authentication. */ +public final class SessionStoreException extends RuntimeException { + SessionStoreException(java.sql.SQLException cause) { super("Session storage operation failed", cause); } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java b/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java new file mode 100644 index 0000000..5fa973f --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +import java.util.Objects; + +/** Stable account identity. An empty tenantId denotes a non-tenanted realm, never a wildcard. */ +public record Subject(String realm, String tenantId, String subjectId) { + public Subject { + Objects.requireNonNull(realm, "realm"); + Objects.requireNonNull(tenantId, "tenantId"); + Objects.requireNonNull(subjectId, "subjectId"); + if (!realm.matches("[a-z0-9][a-z0-9._-]{0,62}")) throw new IllegalArgumentException("Invalid realm"); + if (tenantId.codePoints().anyMatch(cp -> cp >= 0xD800 && cp <= 0xDFFF) + || subjectId.codePoints().anyMatch(cp -> cp >= 0xD800 && cp <= 0xDFFF)) + throw new IllegalArgumentException("Identity contains invalid Unicode"); + if (tenantId.length() > 255 || tenantId.indexOf(0) >= 0) throw new IllegalArgumentException("Invalid tenantId"); + if (subjectId.isBlank() || subjectId.length() > 255 || subjectId.indexOf(0) >= 0) throw new IllegalArgumentException("Invalid subjectId"); + } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java b/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java new file mode 100644 index 0000000..4456e7b --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +final class Tokens { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private Tokens() {} + + static String generate() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return "og1_" + ENCODER.encodeToString(bytes); + } + + static byte[] digest(String token) { + if (token == null || token.length() != 47 || !token.startsWith("og1_")) return null; + String encoded = token.substring(4); + if (!encoded.matches("[A-Za-z0-9_-]{43}")) return null; + byte[] bytes = Base64.getUrlDecoder().decode(encoded); + if (bytes.length != 32 || !ENCODER.encodeToString(bytes).equals(encoded)) return null; + return sha256().digest(token.getBytes(StandardCharsets.US_ASCII)); + } + + static long lockKey(Subject subject) { + MessageDigest hash = sha256(); + hash.update("ogiri-session-admission-v1".getBytes(StandardCharsets.US_ASCII)); + for (String part : new String[]{subject.realm(), subject.tenantId(), subject.subjectId()}) { + byte[] bytes = part.getBytes(StandardCharsets.UTF_8); + hash.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); + hash.update(bytes); + } + return ByteBuffer.wrap(hash.digest()).getLong(); + } + + private static MessageDigest sha256() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } + } +} diff --git a/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql b/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql new file mode 100644 index 0000000..dcd01a7 --- /dev/null +++ b/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql @@ -0,0 +1,14 @@ +-- SPDX-License-Identifier: Apache-2.0 +-- Explicit template: copy into an application-owned migration. Never auto-applied by Ogiri. +CREATE TABLE ogiri_sessions ( + id uuid PRIMARY KEY, + realm varchar(63) COLLATE "C" NOT NULL, + tenant_id varchar(255) COLLATE "C" NOT NULL, + subject_id varchar(255) COLLATE "C" NOT NULL, + client varchar(255) NOT NULL, + token_hash bytea NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL CHECK (expires_at > created_at) +); +CREATE INDEX ogiri_sessions_owner ON ogiri_sessions (realm, tenant_id, subject_id, expires_at); +CREATE INDEX ogiri_sessions_expiry ON ogiri_sessions (expires_at, id); diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java b/ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java new file mode 100644 index 0000000..a246e3b --- /dev/null +++ b/ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.quantipixels.ogiri; + +import static org.junit.jupiter.api.Assertions.*; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; +import org.junit.jupiter.api.*; +import org.postgresql.ds.PGSimpleDataSource; + +class PostgresSessionsTest { + private static PGSimpleDataSource dataSource; + private static final Subject OWNER = new Subject("users", "tenant-a", "user-42"); + private PostgresSessions sessions; + + @BeforeAll static void database() throws Exception { + dataSource = source(); + try (Connection connection = dataSource.getConnection(); var statement = connection.createStatement(); + var schema = PostgresSessions.class.getResourceAsStream("/META-INF/ogiri/schema-postgresql.sql")) { + assertNotNull(schema); + statement.execute("DROP TABLE IF EXISTS ogiri_sessions"); + statement.execute(new String(schema.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static PGSimpleDataSource source() { + PGSimpleDataSource source = new PGSimpleDataSource(); + source.setURL(java.util.Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL"), "Set OGIRI_TEST_JDBC_URL to a disposable PostgreSQL database")); + source.setUser(System.getenv("OGIRI_TEST_JDBC_USER")); + source.setPassword(System.getenv("OGIRI_TEST_JDBC_PASSWORD")); + source.setConnectTimeout(3); + source.setSocketTimeout(10); + return source; + } + + @BeforeEach void reset() throws Exception { + sql("TRUNCATE ogiri_sessions"); + sessions = new PostgresSessions(dataSource); + } + + @Test void issuanceRoundTripsWithoutPersistingOrPrintingTheCredential() throws Exception { + var issued = sessions.issue(OWNER, "phone"); + assertEquals(47, issued.token().length()); + assertEquals(issued.session(), sessions.authenticate(issued.token()).orElseThrow()); + assertTrue(sessions.authenticate(issued.session().id().toString()).isEmpty()); + assertFalse(issued.toString().contains(issued.token())); + assertFalse(issued.session().toString().contains(issued.token())); + try (var connection = dataSource.getConnection(); var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT token_hash, octet_length(token_hash), expires_at - created_at FROM ogiri_sessions")) { + assertTrue(rows.next()); + assertEquals(32, rows.getInt(2)); + assertArrayEquals(java.security.MessageDigest.getInstance("SHA-256").digest(issued.token().getBytes(StandardCharsets.US_ASCII)), rows.getBytes(1)); + } + assertEquals(Duration.ofDays(7), Duration.between(issued.session().createdAt(), issued.session().expiresAt())); + } + + @Test void malformedAndNonCanonicalCredentialsNeverBecomeDatabaseLookups() { + PGSimpleDataSource unavailable = source(); + unavailable.setPortNumbers(new int[]{1}); + var offline = new PostgresSessions(unavailable); + for (String token : new String[]{"", "og1_", "og1_" + "A".repeat(42), "og1_" + "A".repeat(44), "og1_" + "A".repeat(42) + "B", "og1_" + "!".repeat(43), "Bearer " + "A".repeat(43), "x".repeat(100_000)}) { + assertTrue(offline.authenticate(token).isEmpty(), "Malformed credential must fail before I/O"); + } + assertTrue(offline.authenticate(null).isEmpty()); + assertThrows(SessionStoreException.class, () -> offline.authenticate("og1_" + "A".repeat(43))); + } + + @Test void authenticationDoesNotWriteOrSlideExpiryAndExpiredRowsCannotAuthenticate() throws Exception { + var issued = sessions.issue(OWNER, "browser"); + String before = scalar("SELECT xmin::text FROM ogiri_sessions"); + for (int i = 0; i < 5; i++) assertEquals(issued.session(), sessions.authenticate(issued.token()).orElseThrow()); + assertEquals(before, scalar("SELECT xmin::text FROM ogiri_sessions"), "Read authentication must not create new row versions"); + sql("UPDATE ogiri_sessions SET created_at = '2000-01-01Z', expires_at = '2000-01-02Z'"); + assertTrue(sessions.authenticate(issued.token()).isEmpty()); + assertTrue(sessions.list(OWNER).isEmpty()); + } + + @Test void managementUsesEveryIdentityComponentAndNeverTrustsTheSessionIdAlone() { + var a = sessions.issue(OWNER, "a"); + for (Subject foreign : List.of(new Subject("admins", "tenant-a", "user-42"), new Subject("users", "tenant-b", "user-42"), new Subject("users", "Tenant-a", "user-42"), new Subject("users", "tenant-a", "USER-42"), new Subject("users", "tenant-a", "other"), new Subject("users", "", "user-42"))) { + var b = sessions.issue(foreign, "b"); + assertEquals(List.of(b.session()), sessions.list(foreign)); + assertFalse(sessions.revoke(foreign, a.session().id())); + assertEquals(1, sessions.revokeAll(foreign)); + assertTrue(sessions.authenticate(a.token()).isPresent()); + } + var unusual = new Subject("users", "tenant:a", "x' OR '1'='1"); + var c = sessions.issue(unusual, "device"); + assertEquals(List.of(c.session()), sessions.list(unusual)); + assertEquals(List.of(a.session()), sessions.list(OWNER)); + } + + @Test void revocationIsImmediateIdempotentAndDoesNotBanFutureSignIns() { + var first = sessions.issue(OWNER, "phone"); + var second = sessions.issue(OWNER, "laptop"); + assertTrue(sessions.revoke(OWNER, first.session().id())); + assertFalse(sessions.revoke(OWNER, first.session().id())); + assertTrue(sessions.authenticate(first.token()).isEmpty()); + assertTrue(sessions.authenticate(second.token()).isPresent()); + assertEquals(1, sessions.revokeAll(OWNER)); + assertEquals(0, sessions.revokeAll(OWNER)); + assertTrue(sessions.authenticate(second.token()).isEmpty()); + assertTrue(sessions.authenticate(sessions.issue(OWNER, "new login").token()).isPresent()); + } + + @Test void admissionRejectsRatherThanEvictingAndRevocationFreesCapacity() { + var limited = new PostgresSessions(dataSource, new SessionPolicy(Duration.ofHours(1), 1)); + var first = limited.issue(OWNER, "phone"); + assertThrows(SessionLimitException.class, () -> limited.issue(OWNER, "laptop")); + assertTrue(limited.authenticate(first.token()).isPresent()); + assertEquals(List.of(first.session()), limited.list(OWNER)); + limited.revoke(OWNER, first.session().id()); + assertTrue(limited.authenticate(limited.issue(OWNER, "laptop").token()).isPresent()); + } + + @Test void concurrentFirstSignInsCannotExceedTheAccountLimit() throws Exception { + ExecutorService workers = Executors.newFixedThreadPool(8); + try { + for (int round = 0; round < 4; round++) { + var subject = new Subject("race", "", "account-" + round); + var start = new CyclicBarrier(8); + List> results = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + results.add(workers.submit(() -> { + start.await(5, TimeUnit.SECONDS); + try { + new PostgresSessions(dataSource, new SessionPolicy(Duration.ofHours(1), 2)).issue(subject, "parallel"); + return true; + } catch (SessionLimitException expected) { return false; } + })); + } + int accepted = 0; + for (var result : results) if (result.get(15, TimeUnit.SECONDS)) accepted++; + assertEquals(2, accepted); + assertEquals(2, sessions.list(subject).size()); + } + } finally { workers.shutdownNow(); assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); } + } + + @Test void cleanupIsBoundedSkipsLockedRowsAndPreservesLiveSessions() throws Exception { + for (int i = 0; i < 4; i++) sessions.issue(OWNER, "expired"); + sql("UPDATE ogiri_sessions SET created_at = '2000-01-01Z', expires_at = '2000-01-02Z'"); + var live = sessions.issue(OWNER, "live"); + try (Connection blocker = dataSource.getConnection()) { + blocker.setAutoCommit(false); + try (var statement = blocker.createStatement(); var rows = statement.executeQuery("SELECT id FROM ogiri_sessions WHERE expires_at < now() ORDER BY expires_at, id LIMIT 1 FOR UPDATE")) { + assertTrue(rows.next()); + UUID locked = rows.getObject(1, UUID.class); + assertEquals(2, sessions.cleanup(2)); + assertEquals(3, Integer.parseInt(scalar("SELECT count(*) FROM ogiri_sessions"))); + assertEquals(1, sessions.cleanup(10)); + assertEquals(1, Integer.parseInt(scalar("SELECT count(*) FROM ogiri_sessions WHERE id = '" + locked + "'"))); + } + blocker.rollback(); + } + assertEquals(1, sessions.cleanup(10)); + assertEquals(0, sessions.cleanup(10)); + assertEquals(List.of(live.session()), sessions.list(OWNER)); + assertTrue(sessions.authenticate(live.token()).isPresent()); + } + + @Test void failedCommitRollsBackAndNeverReturnsACredential() { + PGSimpleDataSource failing = new PGSimpleDataSource() { + @Override public Connection getConnection() throws SQLException { + Connection actual = dataSource.getConnection(); + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) -> { + if (method.getName().equals("commit")) throw new SQLException("simulated pre-commit failure"); + try { return method.invoke(actual, args); } + catch (InvocationTargetException failure) { throw failure.getCause(); } + }); + } + }; + assertThrows(SessionStoreException.class, () -> new PostgresSessions(failing).issue(OWNER, "phone")); + assertTrue(sessions.list(OWNER).isEmpty()); + assertTrue(sessions.authenticate(sessions.issue(OWNER, "retry").token()).isPresent()); + } + + @Test void callerOwnedTransactionsAreRejectedForAuthenticationAndMutation() throws Exception { + var valid = sessions.issue(OWNER, "existing"); + PGSimpleDataSource enlisted = new PGSimpleDataSource() { + @Override public Connection getConnection() throws SQLException { + Connection connection = dataSource.getConnection(); + connection.setAutoCommit(false); + return connection; + } + }; + var invalid = new PostgresSessions(enlisted); + assertThrows(SessionStoreException.class, () -> invalid.authenticate(valid.token())); + assertThrows(SessionStoreException.class, () -> invalid.list(OWNER)); + assertThrows(SessionStoreException.class, () -> invalid.issue(OWNER, "new")); + assertEquals(List.of(valid.session()), sessions.list(OWNER)); + } + + @Test void invalidResourceBoundsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new SessionPolicy(Duration.ZERO, 1)); + assertThrows(IllegalArgumentException.class, () -> new SessionPolicy(Duration.ofDays(366), 1)); + assertThrows(IllegalArgumentException.class, () -> new SessionPolicy(Duration.ofDays(1), 0)); + assertThrows(IllegalArgumentException.class, () -> sessions.cleanup(10_001)); + assertThrows(IllegalArgumentException.class, () -> sessions.issue(OWNER, "")); + assertThrows(IllegalArgumentException.class, () -> new Subject("users", "", "x\0y")); + assertThrows(IllegalArgumentException.class, () -> new Subject("users", "", String.valueOf((char) 0xD800))); + } + + private static void sql(String sql) throws SQLException { + try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { statement.execute(sql); } + } + private static String scalar(String sql) throws SQLException { + try (var connection = dataSource.getConnection(); var statement = connection.createStatement(); var rows = statement.executeQuery(sql)) { rows.next(); return rows.getString(1); } + } +} diff --git a/plans/client-distroless-redesign.md b/plans/client-distroless-redesign.md deleted file mode 100644 index bab53a2..0000000 --- a/plans/client-distroless-redesign.md +++ /dev/null @@ -1,501 +0,0 @@ -# Ogiri Client: Distroless Pluggable Redesign - -## Context - -The current `ogiri-client` is a monolithic HTTP client that wraps `fetch` with `get/post/put/delete` methods. Modern TS projects already have an HTTP stack (axios + react-query, ky, ofetch, etc.) and don't want a competing client. The library should provide **auth primitives** that plug into any existing stack, following the pattern used by Auth0 SPA SDK and MSAL. - -**Requirements:** -- Zero mandatory runtime dependencies ("distroless") -- Consumers bring their own HTTP client (axios, ky, ofetch, native fetch) -- Ship a lightweight fetch wrapper as a convenience, not the primary API -- Separate entrypoint for axios adapter (tree-shaken if unused) - -**Tooling:** -- **Build**: tsup (esbuild) — designed for libraries, no change needed -- **Tests**: Vitest (already Vite-native) — no change needed -- **Lint**: oxlint (replaces ESLint, Rust-based, ~50-100x faster) -- **Format**: oxfmt (replaces Prettier, Rust-based, ~30x faster, 95% Prettier compat) - ---- - -## Architecture - -### Core (`ogiri-security-client`) — zero dependencies - -**`OgiriAuth`** class — the central piece. Manages token state and provides adapter factories: - -```typescript -class OgiriAuth { - constructor(config: { authMethod?: OgiriAuthMethod; storage?: TokenStorage }) - - // Token state - getTokens(): OgiriTokens | null - setTokens(tokens: OgiriTokens): void - clearTokens(): void - isAuthenticated(): boolean - onAuthError(callback: (error: OgiriAuthError) => void): void - - // Adapter factories - createFetchClient(baseURL: string): OgiriFetchClient - headerInjector(): (headers: Record) => Record - - // Low-level (for custom integrations) - injectInto(config: RequestInit): RequestInit - extractFrom(response: Response): void -} -``` - -**Pure functions** remain exported for BYO wiring: -- `injectAuth(config, tokens, method)` — unchanged -- `extractTokens(response)` — unchanged -- `MemoryTokenStorage`, `LocalStorageTokenStorage` — unchanged - -**`OgiriFetchClient`** — thin convenience wrapper (delegates to OgiriAuth for state): -- `get/post/put/delete` over native fetch -- Auto-injects auth, auto-extracts rotated tokens -- Optional — consumers who use axios/ky ignore this entirely - -### Axios adapter (`ogiri-security-client/axios`) — axios as peerDependency - -```typescript -import { createAxiosInterceptors } from 'ogiri-security-client/axios' - -const { request, response } = createAxiosInterceptors(auth) -axiosInstance.interceptors.request.use(request) -axiosInstance.interceptors.response.use(response.onFulfilled, response.onRejected) -``` - ---- - -## File Structure - -``` -ogiri-client/ -├── src/ -│ ├── index.ts # Main entrypoint -│ ├── auth.ts # NEW: OgiriAuth class -│ ├── fetch-client.ts # RENAME from client.ts: OgiriFetchClient -│ ├── types.ts # MODIFY: add OgiriAuthConfig -│ ├── errors.ts # KEEP as-is -│ ├── interceptors.ts # KEEP as-is (pure functions) -│ ├── token-storage.ts # KEEP as-is -│ └── axios/ -│ ├── index.ts # Axios adapter entrypoint -│ └── interceptors.ts # createAxiosInterceptors() -├── tests/ -│ ├── auth.test.ts # NEW -│ ├── fetch-client.test.ts # RENAME from client.test.ts -│ ├── axios-interceptors.test.ts # NEW -│ ├── interceptors.test.ts # KEEP as-is -│ └── token-storage.test.ts # KEEP as-is -├── oxlintrc.json # NEW: oxlint config -├── .oxfmtrc.json # NEW: oxfmt config -├── tsup.config.ts # MODIFY: two entrypoints -├── vitest.config.ts # KEEP as-is -└── package.json # MODIFY: exports, peerDeps, scripts -``` - -### Build: tsup with two entrypoints - -```typescript -entry: ['src/index.ts', 'src/axios/index.ts'] -``` - -### package.json exports - -```json -{ - "exports": { - ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./axios": { "types": "./dist/axios/index.d.ts", "import": "./dist/axios/index.js", "require": "./dist/axios/index.cjs" } - }, - "peerDependencies": { "axios": ">=1.0.0" }, - "peerDependenciesMeta": { "axios": { "optional": true } } -} -``` - ---- - -## Implementation Steps - -### 1. Add oxlint + oxfmt tooling - -- `pnpm add -D oxlint oxfmt` -- Create `oxlintrc.json` with rules appropriate for a library (no-console warn, type-aware checks) -- Create `.oxfmtrc.json` (Prettier-compat defaults: 2-space indent, single quotes, trailing commas) -- Update `package.json` scripts: - - `"lint": "oxlint src/ tests/"` - - `"format": "oxfmt src/ tests/"` - - `"format:check": "oxfmt --check src/ tests/"` -- Remove any eslint/prettier config if present -- Format existing codebase with oxfmt to establish baseline - -### 2. Create `src/auth.ts` — OgiriAuth class - -- Constructor takes `{ authMethod?, storage?, onAuthError? }` -- Wraps token state management (get/set/clear/isAuthenticated) -- `injectInto(config: RequestInit)` — delegates to `injectAuth()` with stored tokens + authMethod -- `extractFrom(response: Response)` — calls `extractTokens()`, stores if present -- `headerInjector()` — returns function that adds auth headers to plain headers object -- `createFetchClient(baseURL)` — factory for OgiriFetchClient - -### 3. Rename `src/client.ts` → `src/fetch-client.ts` - -- Class renamed to `OgiriFetchClient` -- Constructor takes `OgiriAuth` + `baseURL` (no longer owns token state) -- `get/post/put/delete` delegate to `OgiriAuth.injectInto()` and `OgiriAuth.extractFrom()` - -### 4. Create `src/axios/interceptors.ts` - -- `createAxiosInterceptors(auth: OgiriAuth)` returns `{ request, response }` -- Request: reads tokens from auth, injects into `config.headers` -- Response: extracts rotated tokens, stores via auth -- Error: on 401, clears tokens, fires onAuthError, re-throws - -### 5. Create `src/axios/index.ts` - -- Re-exports `createAxiosInterceptors` - -### 6. Update `src/index.ts` - -- Export `OgiriAuth` as primary API -- Export `OgiriFetchClient` as convenience -- Keep exporting pure functions and types - -### 7. Update `src/types.ts` - -- Add `OgiriAuthConfig` interface -- Keep all existing types - -### 8. Update `tsup.config.ts` - -- Two entrypoints: `src/index.ts`, `src/axios/index.ts` - -### 9. Update `package.json` - -- Add `exports` map with `./axios` subpath -- Add `peerDependencies` for axios (optional) -- Add `axios` types to devDependencies -- Update scripts for oxlint/oxfmt - -### 10. Update tests - -- `tests/auth.test.ts` — OgiriAuth state, injectInto, extractFrom, headerInjector -- `tests/fetch-client.test.ts` — adapt client.test.ts to use OgiriAuth -- `tests/axios-interceptors.test.ts` — axios adapter request/response/401 handling -- `tests/interceptors.test.ts` — keep as-is -- `tests/token-storage.test.ts` — keep as-is - -### 11. Update README.md - -- Primary example: OgiriAuth + axios -- Secondary: OgiriAuth + fetch client -- Advanced: pure functions for BYO - ---- - -## Consumer Usage Examples - -### Axios + React Query -```typescript -import { OgiriAuth, LocalStorageTokenStorage } from 'ogiri-security-client' -import { createAxiosInterceptors } from 'ogiri-security-client/axios' -import axios from 'axios' - -const auth = new OgiriAuth({ - storage: new LocalStorageTokenStorage(), - onAuthError: () => router.push('/login'), -}) - -const api = axios.create({ baseURL: 'https://api.example.com' }) -const { request, response } = createAxiosInterceptors(auth) -api.interceptors.request.use(request) -api.interceptors.response.use(response.onFulfilled, response.onRejected) - -const { data } = useQuery({ queryKey: ['users'], queryFn: () => api.get('/users') }) -``` - -### Built-in fetch client -```typescript -const auth = new OgiriAuth({ storage: new MemoryTokenStorage() }) -const client = auth.createFetchClient('https://api.example.com') -const { data } = await client.post('/api/auth/login', { username, password }) -``` - -### BYO HTTP client (ky, ofetch, etc.) -```typescript -const auth = new OgiriAuth() -const headers = auth.headerInjector()({ 'Content-Type': 'application/json' }) -auth.extractFrom(response) -``` - ---- - -## Files Changed - -| File | Action | -|------|--------| -| `src/auth.ts` | CREATE — OgiriAuth class | -| `src/fetch-client.ts` | CREATE (replaces client.ts) — OgiriFetchClient | -| `src/client.ts` | DELETE | -| `src/axios/index.ts` | CREATE — axios adapter entrypoint | -| `src/axios/interceptors.ts` | CREATE — createAxiosInterceptors | -| `src/index.ts` | MODIFY — new exports | -| `src/types.ts` | MODIFY — add OgiriAuthConfig | -| `src/interceptors.ts` | KEEP as-is | -| `src/token-storage.ts` | KEEP as-is | -| `src/errors.ts` | KEEP as-is | -| `oxlintrc.json` | CREATE — oxlint config | -| `.oxfmtrc.json` | CREATE — oxfmt config | -| `tsup.config.ts` | MODIFY — two entrypoints | -| `vitest.config.ts` | KEEP as-is | -| `package.json` | MODIFY — exports, peerDeps, scripts, devDeps | -| `tests/auth.test.ts` | CREATE | -| `tests/fetch-client.test.ts` | CREATE (replaces client.test.ts) | -| `tests/axios-interceptors.test.ts` | CREATE | -| `tests/interceptors.test.ts` | KEEP as-is | -| `tests/token-storage.test.ts` | KEEP as-is | -| `README.md` | REWRITE | - -## Verification (Client Library) - -1. `pnpm build` — both entrypoints compile, `dist/` has `index.*` and `axios/index.*` -2. `pnpm test` — all tests pass (vitest) -3. `pnpm typecheck` — no type errors -4. `pnpm lint` — oxlint passes -5. `pnpm format:check` — oxfmt passes -6. Verify `dist/` main entrypoint has no axios code -7. Verify pure functions (`injectAuth`, `extractTokens`) still exported from main entrypoint - ---- - -# Part 2: Sample React App - -## Context - -Add a `sample/sample-react` app that demonstrates the pluggable client architecture with a real-world auth flow. Uses MSW to mock the Ogiri backend so it runs standalone without Spring Boot. Showcases axios + React Query integration (the primary consumer pattern). - -## Tech Stack - -| Tool | Purpose | -|------|---------| -| Vite | Dev server + build | -| React 19 | UI | -| React Router | Protected routes | -| Axios | HTTP client | -| TanStack React Query | Server state | -| MSW 2 | Mock API server (browser worker) | -| ogiri-security-client | Auth primitives (local workspace link) | -| oxlint + oxfmt | Lint + format (shared config with ogiri-client) | - -No CSS framework — plain CSS modules. This is a library demo, not a design showcase. - -## Features - -1. **Login/Logout flow** — Form with username/password, token stored in localStorage, logout clears tokens -2. **Protected route** — `/dashboard` redirects to `/login` if unauthenticated -3. **Token rotation display** — Shows current token (truncated), highlights when it changes after a request -4. **Mock API server** — MSW handlers that replicate Ogiri's auth protocol (rotation on every response, batch detection, multi-auth-method support) - -## File Structure - -``` -sample/sample-react/ -├── index.html -├── package.json -├── vite.config.ts -├── tsconfig.json -├── oxlintrc.json -├── public/ -│ └── mockServiceWorker.js # MSW service worker (generated) -├── src/ -│ ├── main.tsx # App entry, MSW init, QueryClientProvider -│ ├── App.tsx # Router setup -│ ├── api/ -│ │ ├── client.ts # OgiriAuth + axios instance + interceptors -│ │ └── queries.ts # React Query hooks (useLogin, useLogout, useProfile, useDemoInfo) -│ ├── auth/ -│ │ ├── AuthProvider.tsx # React context wrapping OgiriAuth state -│ │ ├── useAuth.ts # Hook: { isAuthenticated, tokens, login, logout } -│ │ └── ProtectedRoute.tsx # Route guard component -│ ├── pages/ -│ │ ├── LoginPage.tsx # Login form -│ │ └── DashboardPage.tsx # Protected page showing user info + token rotation -│ ├── components/ -│ │ └── TokenDisplay.tsx # Visual token rotation indicator -│ ├── mocks/ -│ │ ├── browser.ts # MSW browser setup -│ │ ├── handlers.ts # MSW request handlers (login, logout, protected endpoints) -│ │ └── db.ts # In-memory user/token database for mock server -│ └── styles/ -│ └── app.css # Minimal styles -└── README.md -``` - -## MSW Mock Server Design - -The mock server replicates Ogiri's auth protocol discovered from sample-kotlin: - -### `mocks/db.ts` — In-memory state - -```typescript -interface MockUser { id: number; username: string; password: string; email: string } -interface MockSession { token: string; client: string; uid: string; expiry: string; lastRequestAt: number } - -const users: MockUser[] = [ - { id: 1, username: 'user1', password: 'password', email: 'user1@example.com' }, - { id: 2, username: 'user2', password: 'password', email: 'user2@example.com' }, -] - -const sessions: Map = new Map() // keyed by client ID -``` - -### `mocks/handlers.ts` — Endpoint handlers - -| Endpoint | Behavior | -|----------|----------| -| `POST /api/auth/login` | Validates credentials, creates session, returns tokens in headers + body | -| `POST /api/auth/logout` | Extracts client from auth headers, deletes that session | -| `GET /api/me` | Returns user profile if authenticated, 401 otherwise | -| `GET /api/demo/info` | Returns demo data with **rotated tokens in response headers** | - -Key behaviors: -- Every authenticated response returns new `access-token` + `expiry` headers (rotation) -- Batch detection: if last request was <30s ago, skip rotation headers -- Auth extraction: reads from `access-token`/`client`/`uid` headers (header method) -- 401 response clears the session - -### `mocks/browser.ts` - -```typescript -import { setupWorker } from 'msw/browser' -import { handlers } from './handlers' -export const worker = setupWorker(...handlers) -``` - -## Auth Integration (`src/api/client.ts`) - -This is the showcase file — demonstrates the pluggable architecture: - -```typescript -import { OgiriAuth, LocalStorageTokenStorage } from 'ogiri-security-client' -import { createAxiosInterceptors } from 'ogiri-security-client/axios' -import axios from 'axios' - -export const auth = new OgiriAuth({ - authMethod: 'headers', - storage: new LocalStorageTokenStorage(), -}) - -export const api = axios.create({ baseURL: '' }) // same-origin for MSW - -const { request, response } = createAxiosInterceptors(auth) -api.interceptors.request.use(request) -api.interceptors.response.use(response.onFulfilled, response.onRejected) -``` - -## React Query Hooks (`src/api/queries.ts`) - -```typescript -export function useLogin() { - return useMutation({ - mutationFn: (creds: { username: string; password: string }) => - api.post('/api/auth/login', creds), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }), - }) -} - -export function useProfile() { - return useQuery({ - queryKey: ['me'], - queryFn: () => api.get('/api/me').then(r => r.data), - enabled: auth.isAuthenticated(), - }) -} -``` - -## Auth Context (`src/auth/AuthProvider.tsx`) - -Wraps `OgiriAuth` in React context so components can reactively access auth state: - -```typescript -const AuthContext = createContext<{ - isAuthenticated: boolean - tokens: OgiriTokens | null - login: (tokens: OgiriTokens) => void - logout: () => void -}>() -``` - -Uses `useSyncExternalStore` to subscribe to OgiriAuth state changes without polling. - -## Token Rotation Display (`src/components/TokenDisplay.tsx`) - -Shows: -- Current `access-token` (first 8 chars + `...`) -- Current `client` ID -- Current `expiry` timestamp -- Flash/highlight animation when token changes (detected via `usePrevious` comparison) - -## Protected Route (`src/auth/ProtectedRoute.tsx`) - -```typescript -function ProtectedRoute({ children }: { children: ReactNode }) { - const { isAuthenticated } = useAuth() - if (!isAuthenticated) return - return children -} -``` - -## Implementation Steps - -### 12. Scaffold sample-react project - -- `pnpm create vite sample/sample-react --template react-ts` -- Add dependencies: `axios`, `@tanstack/react-query`, `react-router-dom`, `msw` -- Add devDependencies: `oxlint`, `oxfmt` -- Link ogiri-client: `"ogiri-security-client": "workspace:*"` in package.json -- Configure vite.config.ts, tsconfig.json - -### 13. Create MSW mock server - -- `mocks/db.ts` — users + session store -- `mocks/handlers.ts` — auth protocol handlers with rotation -- `mocks/browser.ts` — MSW browser worker -- Generate service worker: `npx msw init public/` - -### 14. Create auth integration layer - -- `api/client.ts` — OgiriAuth + axios + interceptors -- `api/queries.ts` — React Query hooks -- `auth/AuthProvider.tsx` — React context -- `auth/useAuth.ts` — convenience hook -- `auth/ProtectedRoute.tsx` — route guard - -### 15. Create pages and components - -- `pages/LoginPage.tsx` — login form, calls useLogin mutation -- `pages/DashboardPage.tsx` — protected, shows profile + token rotation demo -- `components/TokenDisplay.tsx` — live token display with rotation highlight - -### 16. Wire app entry - -- `main.tsx` — MSW init → React render → QueryClientProvider → AuthProvider → Router -- `App.tsx` — Route definitions - -### 17. Add to workspace - -- Update root `pnpm-workspace.yaml` (if exists) or root `package.json` workspaces -- Verify `pnpm install` resolves ogiri-security-client workspace link - -## Verification (Sample React App) - -1. `cd sample/sample-react && pnpm install` -2. `pnpm dev` — app starts, MSW logs "Mocking enabled" in console -3. Navigate to `/` → redirected to `/login` -4. Login with `user1`/`password` → redirected to `/dashboard` -5. Dashboard shows user info and current token -6. Click "Make Request" → token display updates (rotation visible) -7. Click "Logout" → redirected to `/login`, tokens cleared -8. Navigate to `/dashboard` directly → redirected to `/login` -9. `pnpm lint` and `pnpm format:check` pass diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 52fd029..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,3626 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - ogiri-client: - devDependencies: - '@types/node': - specifier: ^25.2.1 - version: 25.2.2 - '@vitest/coverage-v8': - specifier: ^1.6.1 - version: 1.6.1(vitest@1.6.1(@types/node@25.2.2)) - oxfmt: - specifier: ^0.28.0 - version: 0.28.0 - oxlint: - specifier: ^1.43.0 - version: 1.43.0 - tsup: - specifier: ^8.0.0 - version: 8.5.1(postcss@8.5.6)(typescript@5.9.3) - typescript: - specifier: ^5.3.0 - version: 5.9.3 - vitest: - specifier: ^1.2.0 - version: 1.6.1(@types/node@25.2.2) - - sample/sample-react: - dependencies: - '@tanstack/react-query': - specifier: ^5.62.0 - version: 5.90.20(react@19.2.4) - axios: - specifier: ^1.13.5 - version: 1.13.5 - react: - specifier: ^19.0.0 - version: 19.2.4 - react-dom: - specifier: ^19.0.0 - version: 19.2.4(react@19.2.4) - react-router-dom: - specifier: ^7.1.0 - version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - devDependencies: - '@types/react': - specifier: ^19.0.0 - version: 19.2.13 - '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.13) - '@vitejs/plugin-react': - specifier: ^4.3.0 - version: 4.7.0(vite@6.4.1(@types/node@25.2.2)) - msw: - specifier: ^2.7.0 - version: 2.12.9(@types/node@25.2.2)(typescript@5.9.3) - oxfmt: - specifier: ^0.28.0 - version: 0.28.0 - oxlint: - specifier: ^1.43.0 - version: 1.43.0 - typescript: - specifier: ^5.3.0 - version: 5.9.3 - vite: - specifier: ^6.0.0 - version: 6.4.1(@types/node@25.2.2) - vitest: - specifier: 4.0.18 - version: 4.0.18(@types/node@25.2.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3)) - -packages: - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} - - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@mswjs/interceptors@0.41.2': - resolution: {integrity: sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw==} - engines: {node: '>=18'} - - '@open-draft/deferred-promise@2.2.0': - resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - '@open-draft/logger@0.3.0': - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - - '@open-draft/until@2.1.0': - resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - - '@oxfmt/darwin-arm64@0.28.0': - resolution: {integrity: sha512-jmUfF7cNJPw57bEK7sMIqrYRgn4LH428tSgtgLTCtjuGuu1ShREyrkeB7y8HtkXRfhBs4lVY+HMLhqElJvZ6ww==} - cpu: [arm64] - os: [darwin] - - '@oxfmt/darwin-x64@0.28.0': - resolution: {integrity: sha512-S6vlV8S7jbjzJOSjfVg2CimUC0r7/aHDLdUm/3+/B/SU/s1jV7ivqWkMv1/8EB43d1BBwT9JQ60ZMTkBqeXSFA==} - cpu: [x64] - os: [darwin] - - '@oxfmt/linux-arm64-gnu@0.28.0': - resolution: {integrity: sha512-TfJkMZjePbLiskmxFXVAbGI/OZtD+y+fwS0wyW8O6DWG0ARTf0AipY9zGwGoOdpFuXOJceXvN4SHGLbYNDMY4Q==} - cpu: [arm64] - os: [linux] - - '@oxfmt/linux-arm64-musl@0.28.0': - resolution: {integrity: sha512-7fyQUdW203v4WWGr1T3jwTz4L7KX9y5DeATryQ6fLT6QQp9GEuct8/k0lYhd+ys42iTV/IkJF20e3YkfSOOILg==} - cpu: [arm64] - os: [linux] - - '@oxfmt/linux-x64-gnu@0.28.0': - resolution: {integrity: sha512-sRKqAvEonuz0qr1X1ncUZceOBJerKzkO2gZIZmosvy/JmqyffpIFL3OE2tqacFkeDhrC+dNYQpusO8zsfHo3pw==} - cpu: [x64] - os: [linux] - - '@oxfmt/linux-x64-musl@0.28.0': - resolution: {integrity: sha512-fW6czbXutX/tdQe8j4nSIgkUox9RXqjyxwyWXUDItpoDkoXllq17qbD7GVc0whrEhYQC6hFE1UEAcDypLJoSzw==} - cpu: [x64] - os: [linux] - - '@oxfmt/win32-arm64@0.28.0': - resolution: {integrity: sha512-D/HDeQBAQRjTbD9OLV6kRDcStrIfO+JsUODDCdGmhRfNX8LPCx95GpfyybpZfn3wVF8Jq/yjPXV1xLkQ+s7RcA==} - cpu: [arm64] - os: [win32] - - '@oxfmt/win32-x64@0.28.0': - resolution: {integrity: sha512-4+S2j4OxOIyo8dz5osm5dZuL0yVmxXvtmNdHB5xyGwAWVvyWNvf7tCaQD7w2fdSsAXQLOvK7KFQrHFe33nJUCA==} - cpu: [x64] - os: [win32] - - '@oxlint/darwin-arm64@1.43.0': - resolution: {integrity: sha512-C/GhObv/pQZg34NOzB6Mk8x0wc9AKj8fXzJF8ZRKTsBPyHusC6AZ6bba0QG0TUufw1KWuD0j++oebQfWeiFXNw==} - cpu: [arm64] - os: [darwin] - - '@oxlint/darwin-x64@1.43.0': - resolution: {integrity: sha512-4NjfUtEEH8ewRQ2KlZGmm6DyrvypMdHwBnQT92vD0dLScNOQzr0V9O8Ua4IWXdeCNl/XMVhAV3h4/3YEYern5A==} - cpu: [x64] - os: [darwin] - - '@oxlint/linux-arm64-gnu@1.43.0': - resolution: {integrity: sha512-75tf1HvwdZ3ebk83yMbSB+moAEWK98mYqpXiaFAi6Zshie7r+Cx5PLXZFUEqkscenoZ+fcNXakHxfn94V6nf1g==} - cpu: [arm64] - os: [linux] - - '@oxlint/linux-arm64-musl@1.43.0': - resolution: {integrity: sha512-BHV4fb36T2p/7bpA9fiJ5ayt7oJbiYX10nklW5arYp4l9/9yG/FQC5J4G1evzbJ/YbipF9UH0vYBAm5xbqGrvw==} - cpu: [arm64] - os: [linux] - - '@oxlint/linux-x64-gnu@1.43.0': - resolution: {integrity: sha512-1l3nvnzWWse1YHibzZ4HQXdF/ibfbKZhp9IguElni3bBqEyPEyurzZ0ikWynDxKGXqZa+UNXTFuU1NRVX1RJ3g==} - cpu: [x64] - os: [linux] - - '@oxlint/linux-x64-musl@1.43.0': - resolution: {integrity: sha512-+jNYgLGRFTJxJuaSOZJBwlYo5M0TWRw0+3y5MHOL4ArrIdHyCthg6r4RbVWrsR1qUfUE1VSSHQ2bfbC99RXqMg==} - cpu: [x64] - os: [linux] - - '@oxlint/win32-arm64@1.43.0': - resolution: {integrity: sha512-dvs1C/HCjCyGTURMagiHprsOvVTT3omDiSzi5Qw0D4QFJ1pEaNlfBhVnOUYgUfS6O7Mcmj4+G+sidRsQcWQ/kA==} - cpu: [arm64] - os: [win32] - - '@oxlint/win32-x64@1.43.0': - resolution: {integrity: sha512-bSuItSU8mTSDsvmmLTepTdCL2FkJI6dwt9tot/k0EmiYF+ArRzmsl4lXVLssJNRV5lJEc5IViyTrh7oiwrjUqA==} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} - cpu: [x64] - os: [win32] - - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tanstack/query-core@5.90.20': - resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} - - '@tanstack/react-query@5.90.20': - resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} - peerDependencies: - react: ^18 || ^19 - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@25.2.2': - resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.13': - resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} - - '@types/statuses@2.0.6': - resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - '@vitest/coverage-v8@1.6.1': - resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} - peerDependencies: - vitest: 1.6.1 - - '@vitest/expect@1.6.1': - resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} - - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - - '@vitest/runner@1.6.1': - resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} - - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - - '@vitest/snapshot@1.6.1': - resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} - - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - - '@vitest/spy@1.6.1': - resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} - - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - - '@vitest/utils@1.6.1': - resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} - - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} - hasBin: true - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} - - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - local-pkg@0.5.1: - resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} - engines: {node: '>=14'} - - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - msw@2.12.9: - resolution: {integrity: sha512-NYbi51C6M3dujGmcmuGemu68jy12KqQPoVWGeroKToLGsBgrwG5ErM8WctoIIg49/EV49SEvYM9WSqO4G7kNeQ==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - typescript: '>= 4.8.x' - peerDependenciesMeta: - typescript: - optional: true - - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - - oxfmt@0.28.0: - resolution: {integrity: sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - oxlint@1.43.0: - resolution: {integrity: sha512-xiqTCsKZch+R61DPCjyqUVP2MhkQlRRYxLRBeBDi+dtQJ90MOgdcjIktvDCgXz0bgtx94EQzHEndsizZjMX2OA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.11.2' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - - p-limit@5.0.0: - resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} - engines: {node: '>=18'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-router-dom@7.13.0: - resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - - react-router@7.13.0: - resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - - strip-literal@2.1.1: - resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tinypool@0.8.4: - resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} - engines: {node: '>=14.0.0'} - - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} - engines: {node: ^20.0.0 || >=22.0.0} - - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} - engines: {node: '>=14.0.0'} - - tinyspy@2.2.1: - resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} - engines: {node: '>=14.0.0'} - - tldts-core@7.0.23: - resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} - - tldts@7.0.23: - resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} - hasBin: true - - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} - engines: {node: '>=16'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - - type-fest@5.4.4: - resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} - engines: {node: '>=20'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - until-async@3.0.2: - resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - vite-node@1.6.1: - resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@1.6.1: - resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.1 - '@vitest/ui': 1.6.1 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@1.2.2: - resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} - engines: {node: '>=12.20'} - - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - -snapshots: - - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@0.2.3': {} - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@inquirer/ansi@1.0.2': {} - - '@inquirer/confirm@5.1.21(@types/node@25.2.2)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@25.2.2) - '@inquirer/type': 3.0.10(@types/node@25.2.2) - optionalDependencies: - '@types/node': 25.2.2 - - '@inquirer/core@10.3.2(@types/node@25.2.2)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.2.2) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 25.2.2 - - '@inquirer/figures@1.0.15': {} - - '@inquirer/type@3.0.10(@types/node@25.2.2)': - optionalDependencies: - '@types/node': 25.2.2 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.10 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@mswjs/interceptors@0.41.2': - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - - '@open-draft/deferred-promise@2.2.0': {} - - '@open-draft/logger@0.3.0': - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - - '@open-draft/until@2.1.0': {} - - '@oxfmt/darwin-arm64@0.28.0': - optional: true - - '@oxfmt/darwin-x64@0.28.0': - optional: true - - '@oxfmt/linux-arm64-gnu@0.28.0': - optional: true - - '@oxfmt/linux-arm64-musl@0.28.0': - optional: true - - '@oxfmt/linux-x64-gnu@0.28.0': - optional: true - - '@oxfmt/linux-x64-musl@0.28.0': - optional: true - - '@oxfmt/win32-arm64@0.28.0': - optional: true - - '@oxfmt/win32-x64@0.28.0': - optional: true - - '@oxlint/darwin-arm64@1.43.0': - optional: true - - '@oxlint/darwin-x64@1.43.0': - optional: true - - '@oxlint/linux-arm64-gnu@1.43.0': - optional: true - - '@oxlint/linux-arm64-musl@1.43.0': - optional: true - - '@oxlint/linux-x64-gnu@1.43.0': - optional: true - - '@oxlint/linux-x64-musl@1.43.0': - optional: true - - '@oxlint/win32-arm64@1.43.0': - optional: true - - '@oxlint/win32-x64@1.43.0': - optional: true - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.57.1': - optional: true - - '@rollup/rollup-android-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-x64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.57.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.57.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.57.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.57.1': - optional: true - - '@sinclair/typebox@0.27.10': {} - - '@standard-schema/spec@1.1.0': {} - - '@tanstack/query-core@5.90.20': {} - - '@tanstack/react-query@5.90.20(react@19.2.4)': - dependencies: - '@tanstack/query-core': 5.90.20 - react: 19.2.4 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/node@25.2.2': - dependencies: - undici-types: 7.16.0 - - '@types/react-dom@19.2.3(@types/react@19.2.13)': - dependencies: - '@types/react': 19.2.13 - - '@types/react@19.2.13': - dependencies: - csstype: 3.2.3 - - '@types/statuses@2.0.6': {} - - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.2))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.1(@types/node@25.2.2) - transitivePeerDependencies: - - supports-color - - '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@25.2.2))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 - test-exclude: 6.0.0 - vitest: 1.6.1(@types/node@25.2.2) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@1.6.1': - dependencies: - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - chai: 4.5.0 - - '@vitest/expect@4.0.18': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - chai: 6.2.2 - tinyrainbow: 3.0.3 - - '@vitest/mocker@4.0.18(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.2))': - dependencies: - '@vitest/spy': 4.0.18 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.9(@types/node@25.2.2)(typescript@5.9.3) - vite: 6.4.1(@types/node@25.2.2) - - '@vitest/pretty-format@4.0.18': - dependencies: - tinyrainbow: 3.0.3 - - '@vitest/runner@1.6.1': - dependencies: - '@vitest/utils': 1.6.1 - p-limit: 5.0.0 - pathe: 1.1.2 - - '@vitest/runner@4.0.18': - dependencies: - '@vitest/utils': 4.0.18 - pathe: 2.0.3 - - '@vitest/snapshot@1.6.1': - dependencies: - magic-string: 0.30.21 - pathe: 1.1.2 - pretty-format: 29.7.0 - - '@vitest/snapshot@4.0.18': - dependencies: - '@vitest/pretty-format': 4.0.18 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@1.6.1': - dependencies: - tinyspy: 2.2.1 - - '@vitest/spy@4.0.18': {} - - '@vitest/utils@1.6.1': - dependencies: - diff-sequences: 29.6.3 - estree-walker: 3.0.3 - loupe: 2.3.7 - pretty-format: 29.7.0 - - '@vitest/utils@4.0.18': - dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 - - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - any-promise@1.3.0: {} - - assertion-error@1.1.0: {} - - assertion-error@2.0.1: {} - - asynckit@0.4.0: {} - - axios@1.13.5: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - balanced-match@1.0.2: {} - - baseline-browser-mapping@2.9.19: {} - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - bundle-require@5.1.0(esbuild@0.27.3): - dependencies: - esbuild: 0.27.3 - load-tsconfig: 0.2.5 - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - caniuse-lite@1.0.30001769: {} - - chai@4.5.0: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 - - chai@6.2.2: {} - - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - cli-width@4.1.0: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@4.1.1: {} - - concat-map@0.0.1: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - - convert-source-map@2.0.0: {} - - cookie@1.1.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 - - delayed-stream@1.0.0: {} - - diff-sequences@29.6.3: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - electron-to-chromium@1.5.286: {} - - emoji-regex@8.0.0: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - escalade@3.2.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - - expect-type@1.3.0: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 - - follow-redirects@1.15.11: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-func-name@2.0.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@8.0.1: {} - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - gopd@1.2.0: {} - - graphql@16.12.0: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - headers-polyfill@4.0.3: {} - - html-escaper@2.0.2: {} - - human-signals@5.0.0: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-fullwidth-code-point@3.0.0: {} - - is-node-process@1.2.0: {} - - is-stream@3.0.0: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - joycon@3.1.1: {} - - js-tokens@4.0.0: {} - - js-tokens@9.0.1: {} - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - local-pkg@0.5.1: - dependencies: - mlly: 1.8.0 - pkg-types: 1.3.1 - - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.3.5: - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - - math-intrinsics@1.1.0: {} - - merge-stream@2.0.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mimic-fn@4.0.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - ms@2.1.3: {} - - msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3): - dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.2.2) - '@mswjs/interceptors': 0.41.2 - '@open-draft/deferred-promise': 2.2.0 - '@types/statuses': 2.0.6 - cookie: 1.1.1 - graphql: 16.12.0 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - rettime: 0.10.1 - statuses: 2.0.2 - strict-event-emitter: 0.5.1 - tough-cookie: 6.0.0 - type-fest: 5.4.4 - until-async: 3.0.2 - yargs: 17.7.2 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/node' - - mute-stream@2.0.0: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.11: {} - - node-releases@2.0.27: {} - - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - - object-assign@4.1.1: {} - - obug@2.1.1: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - outvariant@1.4.3: {} - - oxfmt@0.28.0: - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/darwin-arm64': 0.28.0 - '@oxfmt/darwin-x64': 0.28.0 - '@oxfmt/linux-arm64-gnu': 0.28.0 - '@oxfmt/linux-arm64-musl': 0.28.0 - '@oxfmt/linux-x64-gnu': 0.28.0 - '@oxfmt/linux-x64-musl': 0.28.0 - '@oxfmt/win32-arm64': 0.28.0 - '@oxfmt/win32-x64': 0.28.0 - - oxlint@1.43.0: - optionalDependencies: - '@oxlint/darwin-arm64': 1.43.0 - '@oxlint/darwin-x64': 1.43.0 - '@oxlint/linux-arm64-gnu': 1.43.0 - '@oxlint/linux-arm64-musl': 1.43.0 - '@oxlint/linux-x64-gnu': 1.43.0 - '@oxlint/linux-x64-musl': 1.43.0 - '@oxlint/win32-arm64': 1.43.0 - '@oxlint/win32-x64': 1.43.0 - - p-limit@5.0.0: - dependencies: - yocto-queue: 1.2.2 - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-key@4.0.0: {} - - path-to-regexp@6.3.0: {} - - pathe@1.1.2: {} - - pathe@2.0.3: {} - - pathval@1.1.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - pirates@4.0.7: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - postcss-load-config@6.0.1(postcss@8.5.6): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - postcss: 8.5.6 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - proxy-from-env@1.1.0: {} - - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react-is@18.3.1: {} - - react-refresh@0.17.0: {} - - react-router-dom@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-router: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - - react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - cookie: 1.1.1 - react: 19.2.4 - set-cookie-parser: 2.7.2 - optionalDependencies: - react-dom: 19.2.4(react@19.2.4) - - react@19.2.4: {} - - readdirp@4.1.2: {} - - require-directory@2.1.1: {} - - resolve-from@5.0.0: {} - - rettime@0.10.1: {} - - rollup@4.57.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 - fsevents: 2.3.3 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - set-cookie-parser@2.7.2: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} - - source-map-js@1.2.1: {} - - source-map@0.7.6: {} - - stackback@0.0.2: {} - - statuses@2.0.2: {} - - std-env@3.10.0: {} - - strict-event-emitter@0.5.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-final-newline@3.0.0: {} - - strip-literal@2.1.1: - dependencies: - js-tokens: 9.0.1 - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tagged-tag@1.0.0: {} - - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyexec@1.0.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tinypool@0.8.4: {} - - tinypool@2.1.0: {} - - tinyrainbow@3.0.3: {} - - tinyspy@2.2.1: {} - - tldts-core@7.0.23: {} - - tldts@7.0.23: - dependencies: - tldts-core: 7.0.23 - - tough-cookie@6.0.0: - dependencies: - tldts: 7.0.23 - - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} - - tsup@8.5.1(postcss@8.5.6)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.3) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.3 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6) - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - type-detect@4.1.0: {} - - type-fest@5.4.4: - dependencies: - tagged-tag: 1.0.0 - - typescript@5.9.3: {} - - ufo@1.6.3: {} - - undici-types@7.16.0: {} - - until-async@3.0.2: {} - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - vite-node@1.6.1(@types/node@25.2.2): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.21(@types/node@25.2.2) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@25.2.2): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.57.1 - optionalDependencies: - '@types/node': 25.2.2 - fsevents: 2.3.3 - - vite@6.4.1(@types/node@25.2.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.2.2 - fsevents: 2.3.3 - - vitest@1.6.1(@types/node@25.2.2): - dependencies: - '@vitest/expect': 1.6.1 - '@vitest/runner': 1.6.1 - '@vitest/snapshot': 1.6.1 - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - acorn-walk: 8.3.4 - chai: 4.5.0 - debug: 4.4.3 - execa: 8.0.1 - local-pkg: 0.5.1 - magic-string: 0.30.21 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 - tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.4.21(@types/node@25.2.2) - vite-node: 1.6.1(@types/node@25.2.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.2.2 - transitivePeerDependencies: - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vitest@4.0.18(@types/node@25.2.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3)): - dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@25.2.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.2.2 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@1.2.2: {} - - yoctocolors-cjs@2.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 781616c..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - 'sample/sample-react' diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8b948ec --- /dev/null +++ b/pom.xml @@ -0,0 +1,34 @@ + + 4.0.0 + com.quantipixels.ogiri + ogiri-parent + 0.1.0 + pom + Ogiri + Revocable opaque sessions for PostgreSQL, with optional Spring Security integration. + https://github.com/quantipixels/ogiri + Apache License 2.0https://www.apache.org/licenses/LICENSE-2.0 + quantipixelsQuanti Pixels + https://github.com/quantipixels/ogiriscm:git:https://github.com/quantipixels/ogiri.git + ogiriogiri-spring-security + + 17 + UTF-8 + 2026-09-07T00:00:00Z + 7.1.1 + 6.1.3 + 42.7.13 + + + org.springframework.securityspring-security-bom${spring-security.version}pomimport + org.junitjunit-bom${junit.version}pomimport + org.postgresqlpostgresql${postgresql.version} + + + org.apache.maven.pluginsmaven-compiler-plugin3.14.1truetrue + org.apache.maven.pluginsmaven-surefire-plugin3.5.6true + org.apache.maven.pluginsmaven-jar-plugin3.4.2 + org.apache.maven.pluginsmaven-source-plugin3.3.1sourcesjar-no-fork + org.apache.maven.pluginsmaven-javadoc-plugin3.11.2all,-missingtruejavadocjar + + diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7cc1a1c..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "ogiri-docs" -version = "3.0.1" -description = "Documentation for Ogiri Security" -requires-python = ">=3.9" -dependencies = [ - "mkdocs-material>=9.7.0", - "mike>=2.1.3", - "mkdocs-macros-plugin>=1.5.0", - "mkdocs-minify-plugin>=0.8.0", - "mkdocs-git-revision-date-localized-plugin>=1.5.0", - "mkdocs-redirects>=1.2.2", -] - -[tool.setuptools] -# This is a documentation-only package with no Python code -# Tell setuptools to not search for packages (avoiding gradle/, sample/, etc.) -py-modules = [] diff --git a/release.gradle.kts b/release.gradle.kts deleted file mode 100644 index a1e0304..0000000 --- a/release.gradle.kts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Release configuration for ogiri project. - * - * Usage: - * - Automated release via GitLab CI/CD: See .gitlab-ci.yml - * - Manual release: ./gradlew release -Pversion=X.Y.Z - * - Snapshot deployment: Automatic on main branch - * - * Environment variables required for Maven Central publishing: - * - OSSRH_USERNAME: Sonatype OSSRH username - * - OSSRH_PASSWORD: Sonatype OSSRH password - * - GPG_KEY_ID: GPG key ID for signing - * - GPG_PASSPHRASE: GPG key passphrase - * - GPG_PRIVATE_KEY: GPG private key (base64 encoded for CI/CD) - * - * Version bumping: - * - Major: breaking changes, e.g., 0.1.0 -> 1.0.0 - * - Minor: new features, e.g., 0.1.0 -> 0.2.0 - * - Patch: bug fixes, e.g., 0.1.0 -> 0.1.1 - */ -tasks.register("release") { - group = "release" - description = "Release the project to Maven Central" - - finalizedBy("publish") - - doLast { - val version = project.findProperty("version")?.toString() ?: "0.1.0" - val isSnapshot = version.endsWith("-SNAPSHOT") - - println("📦 Releasing version: $version") - println("🔐 Snapshot: $isSnapshot") - println("✅ Ready to publish to Maven Central") - } -} - -tasks.register("bumpVersion") { - group = "versioning" - description = "Bump patch version in all build files" - - doLast { - val currentVersion = - project.findProperty("currentVersion")?.toString() - ?: System.getenv("CURRENT_VERSION") ?: "0.1.0-SNAPSHOT" - val newVersion = - project.findProperty("newVersion")?.toString() - ?: System.getenv("NEW_VERSION") ?: bumpPatchVersion(currentVersion) - - println("📌 Bumping version from $currentVersion to $newVersion") - - updateVersionInFile(rootProject.file("ogiri-core/build.gradle.kts"), currentVersion, newVersion) - updateVersionInFile( - rootProject.file("sample/sample-java/build.gradle.kts"), currentVersion, newVersion) - updateVersionInFile( - rootProject.file("sample/sample-kotlin/build.gradle.kts"), currentVersion, newVersion) - - println("✅ Version updated successfully") - } -} - -/** - * Bumps the patch component of a semantic version string and returns the resulting snapshot - * version. - * - * If the input ends with "-SNAPSHOT" that suffix is ignored for parsing. For versions with at least - * three dot-separated segments the patch segment is incremented and "-SNAPSHOT" is appended. For - * shorter versions ".1-SNAPSHOT" is appended to the sanitized input. - * - * @param version The version string to bump (may include a trailing "-SNAPSHOT"). - * @return The new version string with a trailing "-SNAPSHOT". - * @throws IllegalArgumentException if the version has a third segment that cannot be parsed as an - * integer. - */ -fun bumpPatchVersion(version: String): String { - val sanitized = version.replace("-SNAPSHOT", "") - val parts = sanitized.split(".") - return if (parts.size >= 3) { - val patch = - parts[2].toIntOrNull() - ?: throw IllegalArgumentException( - "Invalid version format for auto-bumping: '$version'. " + - "Patch segment '${parts[2]}' is not a number. Please provide newVersion manually.") - "${parts[0]}.${parts[1]}.${patch + 1}-SNAPSHOT" - } else { - "$sanitized.1-SNAPSHOT" - } -} - -/** - * Updates occurrences of a specific version assignment in the given file to a new version. - * - * Matches lines of the form `version = "x.y.z"` or `project.version = 'x.y.z'` (allowing - * surrounding whitespace), replaces only when the assigned value exactly equals `oldVersion`, and - * preserves the surrounding quotes and any `project.` prefix. If the file does not exist the - * function logs a warning and returns without error. If a change is made the file is overwritten - * and a confirmation is logged. - * - * @param file The file to scan and potentially update. - * @param oldVersion The exact version string to replace (must match the assigned value in the - * file). - * @param newVersion The version string to write in place of `oldVersion`. - */ -fun updateVersionInFile(file: File, oldVersion: String, newVersion: String) { - if (!file.exists()) { - println("⚠️ File not found: ${file.absolutePath}") - return - } - - val content = file.readText() - val versionRegex = - Regex( - """^(\s*(?:project\.)?version\s*=\s*["'])${Regex.escape(oldVersion)}(["'])""", - RegexOption.MULTILINE) - val updated = - versionRegex.replace(content) { matchResult -> - "${matchResult.groupValues[1]}$newVersion${matchResult.groupValues[2]}" - } - - if (content != updated) { - file.writeText(updated) - println("✏️ Updated ${file.name}") - } -} diff --git a/sample/README.md b/sample/README.md deleted file mode 100644 index 4615c28..0000000 --- a/sample/README.md +++ /dev/null @@ -1,185 +0,0 @@ -# Sample Applications - -Minimal applications demonstrating Ogiri token auth integration. - -## Available Samples - -| Sample | Language | Path | Port | -| ------ | ---------- | ---------------- | ----- | -| Java | Pure Java | `sample-java/` | 48080 | -| Kotlin | Kotlin | `sample-kotlin/` | 48081 | -| React | TypeScript | `sample-react/` | 5173 | - -Both server samples implement the same functionality: - -- `OgiriUserDirectory` — in-memory user directory -- `OgiriRouteRegistry` — public route declarations -- `OgiriTokenRepository` — JPA token persistence (H2 in-memory) -- Login / logout endpoints -- Demo endpoints for all three auth methods (headers, cookies, Bearer token) - -## Prerequisites - -- Java 17+ - -No external database required — both server samples use H2 in-memory. - -## Quick Start - -### Spring Boot servers - -```bash -# Kotlin (port 48081) -./gradlew :sample:sample-kotlin:bootRun - -# Java (port 48080) -./gradlew :sample:sample-java:bootRun -``` - -### React sample (standalone with mock) - -```bash -cd sample-react -pnpm install -pnpm dev # http://localhost:5173 -``` - -### React + Kotlin (full stack) - -```bash -cd sample-react -./run-live.sh kotlin --ui -# → starts Spring Boot on :48081 -# → starts Vite on :5173, /api/* proxied to :48081 -# → open http://localhost:5173 -``` - -### React + Java (full stack) - -```bash -cd sample-react -./run-live.sh java --ui -# → starts Spring Boot on :48080 -# → starts Vite on :5173, /api/* proxied to :48080 -# → open http://localhost:5173 -``` - -### Integration tests only (no UI) - -```bash -cd sample-react -./run-live.sh kotlin # or java -``` - -## Default Credentials - -Login accepts **email** (not username): - -| Email | Password | -| ------------------- | ---------- | -| `user1@example.com` | `password` | -| `user2@example.com` | `password` | - -## Test Endpoints - -```bash -# Health check (public) -curl http://localhost:48081/api/health - -# Login -curl -X POST http://localhost:48081/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1@example.com","password":"password"}' \ - -v - -# Authenticated request (extract tokens from login response headers) -curl http://localhost:48081/api/demo/info \ - -H "access-token: " \ - -H "client: " \ - -H "uid: " \ - -H "expiry: " - -# Cookie-based auth -curl -X POST http://localhost:48081/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1@example.com","password":"password"}' \ - -c cookies.txt - -curl http://localhost:48081/api/demo/cookies -b cookies.txt - -# Bearer token auth (Authorization header from login response) -curl http://localhost:48081/api/demo/bearer \ - -H "Authorization: Bearer " - -# Logout -curl -X POST http://localhost:48081/api/auth/logout \ - -H "access-token: " \ - -H "client: " \ - -H "uid: " \ - -H "expiry: " -``` - -Replace `48081` with `48080` for the Java sample. - -## Project Structure - -```text -sample-kotlin/ -├── src/main/kotlin/.../ -│ ├── Application.kt -│ ├── config/SecurityConfig.kt -│ ├── security/ -│ │ ├── SampleOgiriUserDirectory.kt -│ │ └── SampleRouteRegistry.kt -│ ├── repository/SampleTokenRepository.kt -│ └── controller/ -│ ├── AuthController.kt -│ ├── DemoController.kt -│ └── HealthController.kt -└── src/main/resources/application.yml - -sample-react/ -├── src/ -│ ├── lib/ -│ │ ├── auth.ts # OgiriAuth — copy into your project -│ │ └── axios-ogiri.ts # axios interceptors — copy alongside auth.ts -│ ├── api/client.ts # axios instance wired to OgiriAuth -│ ├── auth/AuthProvider.tsx # React context + useSyncExternalStore -│ └── mocks/ # MSW handlers replicating the Ogiri protocol -└── run-live.sh # Start server → run tests or UI → teardown -``` - -## Authentication Methods - -All three methods are functionally equivalent. The filter extracts auth from headers first, then cookies, then Bearer tokens. - -1. **HTTP Headers** — `access-token`, `client`, `uid`, `expiry` request headers -2. **Secure Cookies** — HTTPOnly cookies, set automatically by the server on login -3. **Bearer Token** — `Authorization: Bearer ` header - -## Server Configuration - -Both samples use H2 in-memory with these Ogiri defaults: - -```yaml -ogiri: - security: - register-filter: true - auth: - batch-grace-seconds: 30 - rotate-stale-seconds: 3600 - cookies: - enabled: true - secure: false # false for localhost; set true in production (HTTPS) - http-only: true - same-site: Lax -``` - -## Using as Template - -1. Copy the sample directory structure -2. Replace `SampleOgiriUserDirectory` with database lookups -3. Configure your database connection -4. Add your business logic endpoints - -See [Quickstart Guide](../docs/quickstart.md) for integration details. diff --git a/sample/sample-java/README.md b/sample/sample-java/README.md deleted file mode 100644 index 218d481..0000000 --- a/sample/sample-java/README.md +++ /dev/null @@ -1,353 +0,0 @@ -# Ògiri Security Java Sample Application - -A complete example demonstrating how to integrate the **ogiri** token-based authentication library into a Spring Boot application using pure Java. - -## Overview - -This sample application showcases: - -- Token-based authentication with rotating tokens -- Token rotation and batch grace windows -- Sub-token management (extensible for device-specific tokens, etc.) -- User authentication with Spring Security -- Route-based access control (public vs. authenticated endpoints) -- Database persistence with Spring Data JPA - -## Prerequisites - -- **Java 17+** -- **Gradle** (or use the provided Gradle wrapper) -- **H2** (in-memory, included by default) or **PostgreSQL** (optional) - -## Configuration - -### Default (In-Memory H2) - -The application runs with H2 in-memory database by default. This requires zero setup and is ideal for development and testing. - -### PostgreSQL Setup (Optional) - -To use PostgreSQL instead: - -1. Create a PostgreSQL database: - -```bash -createdb ogiri_sample_java -``` - -2. Create `src/main/resources/application-postgres.yml`: - -```yaml -spring: - datasource: - url: jdbc:postgresql://localhost:5432/ogiri_sample_java - username: postgres - password: your_password - driver-class-name: org.postgresql.Driver - jpa: - hibernate: - ddl-auto: validate - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect -``` - -3. Run with the postgres profile: - -```bash -./gradlew :sample:sample-java:bootRun --args='--spring.profiles.active=postgres' -``` - -### ogiri Security Configuration - -The library is auto-configured in `com.quantipixels.ogiri.samples.java.config.SecurityConfig`. Key configuration properties: - -```yaml -ogiri: - auth: - max-clients: 24 # Max concurrent clients per user - batch-grace-seconds: 30 # Grace period for token batch requests - token-lifespan-days: 14 # Token expiration in days - security: - register-filter: true # Auto-register authentication filter -``` - -## Running the Application - -### Default (In-Memory H2) - -From the repository root: - -```bash -./gradlew :sample:sample-java:bootRun -``` - -The application starts on `http://localhost:48080` with an in-memory H2 database. No database setup required. - -### With PostgreSQL - -First, follow the PostgreSQL setup steps above, then: - -```bash -./gradlew :sample:sample-java:bootRun --args='--spring.profiles.active=postgres' -``` - -## API Endpoints - -### Public Endpoints (No Authentication Required) - -- **POST /api/auth/login** - Authenticate and obtain tokens - - ```bash - curl -X POST http://localhost:48080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1","password":"password"}' \ - -v - ``` - - Response includes tokens in headers, cookies (if enabled), and body. - -- **GET /api/health** - Application health check - ```bash - curl http://localhost:48080/api/health - ``` - -### Secured Endpoints (Authentication Required) - -The sample demonstrates **three authentication methods**. All methods are functionally equivalent: - -#### Method 1: HTTP Headers - -```bash -curl http://localhost:48080/api/demo/headers \ - -H "access-token: " \ - -H "client: " \ - -H "uid: " \ - -H "expiry: " -``` - -#### Method 2: Secure Cookies - -```bash -# Login with cookie storage -curl -X POST http://localhost:48080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1","password":"password"}' \ - -c cookies.txt - -# Use stored cookies -curl http://localhost:48080/api/demo/cookies -b cookies.txt -``` - -#### Method 3: Bearer Token - -```bash -# Extract Authorization header from login response -curl http://localhost:48080/api/demo/bearer \ - -H "Authorization: Bearer " -``` - -### Available Endpoints - -| Endpoint | Method | Auth | Description | -| ------------------------ | ------ | ---- | ------------------------------------ | -| `/api/health` | GET | No | Health check | -| `/api/me` | GET | Yes | Current user info | -| `/api/auth/login` | POST | No | Login and get tokens | -| `/api/auth/logout` | POST | Yes | Logout and revoke tokens | -| `/api/demo/headers` | GET | Yes | Test header-based auth | -| `/api/demo/cookies` | GET | Yes | Test cookie-based auth | -| `/api/demo/bearer` | GET | Yes | Test Bearer token auth | -| `/api/demo/info` | GET | Yes | General auth info | -| `/api/test/expire-token` | POST | Yes | Backdate token expiry (dev/test use) | - -### Test Users - -The sample includes two pre-configured users: - -| Username | Password | Email | -| -------- | -------- | ----------------- | -| user1 | password | user1@example.com | -| user2 | password | user2@example.com | - -### Complete Testing Flow - -```bash -# 1. Login and save response headers -curl -X POST http://localhost:48080/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1","password":"password"}' \ - -v 2>&1 | grep -E "< (access-token|client|uid|expiry|Authorization):" - -# 2. Extract tokens and test header auth -TOKEN="" -CLIENT="" -UID="" -EXPIRY="" - -curl http://localhost:48080/api/demo/headers \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $UID" \ - -H "expiry: $EXPIRY" - -# 3. Test general info endpoint -curl http://localhost:48080/api/demo/info \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $UID" \ - -H "expiry: $EXPIRY" - -# 4. Logout -curl -X POST http://localhost:48080/api/auth/logout \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $UID" \ - -H "expiry: $EXPIRY" -``` - -## Key Components - -### Entity - -- **SampleToken** - JPA entity extending `OgiriBaseToken` - -### Repository - -- **SampleTokenRepository** - Spring Data JPA + ogiri `OgiriTokenRepository` interface - -### Security - -- **SampleOgiriUserDirectory** - Implements `OgiriUserDirectory` for user lookup -- **SampleRouteRegistry** - Declares public routes via `OgiriRouteRegistry` -- **SecurityConfig** - Spring Security configuration - -### Service - -- **SampleTokenService** - Extends ogiri `OgiriTokenService` with custom token factory - -### Test Utilities - -- **TestController** - Exposes `POST /api/test/expire-token` to backdate the current session's expiry, enabling the full expiry → 401 → redirect flow without waiting for a real TTL (`@Profile("!jdbc")`) - -## Development - -### Testing - -Run unit tests: - -```bash -./gradlew :sample:sample-java:test -``` - -### Building - -Build the application JAR: - -```bash -./gradlew :sample:sample-java:build -``` - -The JAR will be available at `build/libs/sample-java-*.jar` - -### Debugging - -Enable debug logging by adding to `application.yml`: - -```yaml -logging: - level: - com.quantipixels.ogiri: DEBUG -``` - -## Project Structure - -``` -sample-java/ -├── src/main/java/com/quantipixels/ogiri/samples/java/ -│ ├── Application.java # Entry point -│ ├── config/SecurityConfig.java # Spring Security setup -│ ├── controller/HealthController.java # REST endpoints -│ ├── entity/SampleToken.java # JPA token entity -│ ├── repository/SampleTokenRepository.java # Data access -│ ├── security/ # ogiri integration -│ │ ├── SampleRouteRegistry.java -│ │ ├── SampleOgiriUserDirectory.java -│ │ └── ... -│ └── service/SampleTokenService.java # Token service -├── src/main/resources/ -│ ├── application.yml # Configuration -│ └── db/migration/ # Flyway migrations -├── build.gradle.kts # Gradle build config -└── README.md # This file -``` - -## Extending the Sample - -### Adding Custom Routes - -Modify `SampleRouteRegistry` to declare additional public routes: - -```java -public List routes() { - return List.of( - new Route(HttpMethod.GET, "/api/docs/**", true, false, null), - new Route(HttpMethod.POST, "/api/custom", true, false, null) - ); -} -``` - -### Adding Sub-Tokens - -Implement `OgiriSubTokenRegistration` to create domain-specific tokens (device, chat, etc.): - -```java -@Bean -public OgiriSubTokenRegistration deviceToken() { - return new OgiriSubTokenRegistration() { - @Override - public String getName() { return "device"; } - - @Override - public String clientIdFor(String parentClient) { - return parentClient + ".device"; - } - - @Override - public Instant expiry(Instant parentExpiry) { - return parentExpiry.minus(1, ChronoUnit.HOURS); - } - }; -} -``` - -## Troubleshooting - -### Database Connection Issues - -- Ensure PostgreSQL is running on the configured host/port -- Verify database credentials in `application.yml` -- Check that the database exists and schema is initialized - -### Authentication Failures - -- Confirm token headers are sent with each request (access-token, client, uid, expiry) -- Verify token hasn't expired using the expiry header -- Check that user credentials match values in `SampleOgiriUserDirectory` - -### Token Rotation Issues - -- Token rotation only occurs outside the batch grace window -- By default, 5 second grace period allows requests within that window without rotation -- Adjust `batch-grace-seconds` to change this behavior - -## References - -- [ogiri Documentation](../../docs/) -- [Token Authentication Flow](../../docs/authentication.md) -- [Spring Boot Documentation](https://spring.io/projects/spring-boot) -- [Spring Security](https://spring.io/projects/spring-security) - -## License - -Apache License 2.0 - See [LICENSE](../../LICENSE) file for details diff --git a/sample/sample-java/build.gradle.kts b/sample/sample-java/build.gradle.kts deleted file mode 100644 index e779b99..0000000 --- a/sample/sample-java/build.gradle.kts +++ /dev/null @@ -1,48 +0,0 @@ -plugins { - java - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - id("org.springframework.boot") version libs.versions.springBoot.get() -} - -group = "com.quantipixels.ogiri.samples" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } -} - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -dependencies { - implementation(project(":ogiri-jpa")) - // ogiri-jpa transitively includes ogiri-core and spring-boot-starter-data-jpa - implementation(project(":ogiri-jdbc")) - implementation(project(":ogiri-caffeine")) - implementation("org.springframework.boot:spring-boot-starter-web") - - // Database drivers - runtimeOnly("com.h2database:h2:2.4.240") - runtimeOnly("org.postgresql:postgresql:42.7.13") - - testImplementation(project(":ogiri-test")) - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { useJUnitPlatform() } - -tasks.bootRun { - @Suppress("UNCHECKED_CAST") - systemProperties = - System.getProperties() - .stringPropertyNames() - .associate { it to System.getProperty(it) } - .toMutableMap() as MutableMap -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/Application.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/Application.java deleted file mode 100644 index 86c8e79..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/Application.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.ComponentScan; - -/** - * Spring Boot application demonstrating ogiri token-based authentication in Java. - * - *

This sample shows how to: - Integrate ogiri as a dependency in a Java Spring Boot application - * - Configure required SPI implementations (OgiriUserDirectory, OgiriRouteRegistry) - Use the - * authentication filter and token service - */ -@SpringBootApplication -@ComponentScan( - basePackages = {"com.quantipixels.ogiri.security", "com.quantipixels.ogiri.samples.java"}) -public class Application { - - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/AuthController.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/AuthController.java deleted file mode 100644 index 1f1df47..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/AuthController.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.controller; - -import com.quantipixels.ogiri.security.core.SecurityServiceException; -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory; -import com.quantipixels.ogiri.security.tokens.OgiriTokenService; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.Map; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Authentication controller demonstrating login and logout flows. - * - *

This controller shows how to integrate with OgiriTokenService for authentication operations. - */ -@RestController -@RequestMapping("/api/auth") -public class AuthController { - - private final OgiriTokenService tokenService; - private final OgiriUserDirectory userDirectory; - - public AuthController(OgiriTokenService tokenService, OgiriUserDirectory userDirectory) { - this.tokenService = tokenService; - this.userDirectory = userDirectory; - } - - /** - * Login endpoint that validates credentials and returns authentication tokens. - * - *

On successful login, this endpoint: 1. Validates username and password 2. Creates - * authentication tokens (APP + sub-tokens) 3. Returns tokens in response headers AND body 4. Sets - * secure cookies if cookie config is enabled - * - *

Example request: - * - *

-   * curl -X POST http://localhost:8080/api/auth/login \
-   *   -H "Content-Type: application/json" \
-   *   -d '{"username":"user1","password":"password"}'
-   * 
- * - *

Response includes: - Headers: access-token, client, uid, expiry, Authorization (Bearer) - - * Cookies: access-token, client, uid, expiry (if enabled) - Body: JSON with token details - * - * @param request Login credentials - * @param httpRequest HTTP request for context - * @param httpResponse HTTP response for setting headers/cookies - * @return Authentication response with token details - */ - @PostMapping("/login") - public ResponseEntity login( - @RequestBody LoginRequest request, - HttpServletRequest httpRequest, - HttpServletResponse httpResponse) { - try { - tokenService.verifyUser(httpRequest, httpResponse, request.username(), request.password()); - - String accessToken = httpResponse.getHeader("access-token"); - String client = httpResponse.getHeader("client"); - String uid = httpResponse.getHeader("uid"); - String expiry = httpResponse.getHeader("expiry"); - - return ResponseEntity.ok( - new AuthResponse( - accessToken != null ? accessToken : "", - client != null ? client : "", - uid != null ? uid : "", - expiry != null ? expiry : "", - "Login successful")); - } catch (SecurityServiceException e) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new AuthResponse("", "", "", "", "Invalid credentials")); - } - } - - /** - * Logout endpoint that revokes the current authentication token. - * - *

This endpoint: 1. Extracts the current token from request headers/cookies 2. Revokes the - * token and all associated sub-tokens 3. Clears authentication cookies - * - *

Example request: - * - *

-   * curl -X POST http://localhost:8080/api/auth/logout \
-   *   -H "access-token: <token>" \
-   *   -H "client: <client>" \
-   *   -H "uid: <uid>" \
-   *   -H "expiry: <expiry>"
-   * 
- * - * @param authentication Current authentication context - * @param httpRequest HTTP request for extracting token - * @param httpResponse HTTP response for clearing cookies - * @return Logout confirmation message - */ - @PostMapping("/logout") - public ResponseEntity> logout( - Authentication authentication, - HttpServletRequest httpRequest, - HttpServletResponse httpResponse) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "Not authenticated")); - } - - try { - var user = userDirectory.findByUsername(authentication.getName()); - if (user != null) { - tokenService.revokeClient(user.getOgiriUserId(), httpRequest, httpResponse); - } - return ResponseEntity.ok(Map.of("message", "Logout successful")); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Logout failed: " + e.getMessage())); - } - } - - /** Login request body. */ - public record LoginRequest(String username, String password) {} - - /** Authentication response body. */ - public record AuthResponse( - String accessToken, String client, String uid, String expiry, String message) {} -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/DemoController.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/DemoController.java deleted file mode 100644 index 1d4a0a9..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/DemoController.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.controller; - -import com.quantipixels.ogiri.samples.java.util.SampleAuthUtils; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Demo controller showcasing different authentication methods. - * - *

Ogiri Security supports three authentication methods, all functionally equivalent: - * - *

    - *
  1. HTTP Headers (access-token, client, uid, expiry) - *
  2. Secure Cookies (same four fields) - *
  3. Bearer Token (Authorization: Bearer base64-json) - *
- */ -@RestController -@RequestMapping("/api/demo") -public class DemoController { - - /** Shows authentication via HTTP headers (access-token, client, uid, expiry). */ - @GetMapping("/headers") - public ResponseEntity> demonstrateHeaderAuth( - Authentication authentication, HttpServletRequest request) { - Map result = SampleAuthUtils.authBase("HTTP Headers", authentication); - - Map receivedHeaders = new HashMap<>(); - receivedHeaders.put("access-token", request.getHeader("access-token")); - receivedHeaders.put("client", request.getHeader("client")); - receivedHeaders.put("uid", request.getHeader("uid")); - receivedHeaders.put("expiry", request.getHeader("expiry")); - result.put("receivedHeaders", receivedHeaders); - - return ResponseEntity.ok(result); - } - - /** Shows authentication via secure cookies (same four fields set as HttpOnly cookies). */ - @GetMapping("/cookies") - public ResponseEntity> demonstrateCookieAuth( - Authentication authentication, HttpServletRequest request) { - Map result = SampleAuthUtils.authBase("Secure Cookies", authentication); - - Map cookies = new HashMap<>(); - if (request.getCookies() != null) { - for (Cookie cookie : request.getCookies()) { - cookies.put(cookie.getName(), cookie.getValue()); - } - } - - Map receivedCookies = new HashMap<>(); - receivedCookies.put("access-token", cookies.get("access-token")); - receivedCookies.put("client", cookies.get("client")); - receivedCookies.put("uid", cookies.get("uid")); - receivedCookies.put("expiry", cookies.get("expiry")); - result.put("receivedCookies", receivedCookies); - - return ResponseEntity.ok(result); - } - - /** Shows authentication via Authorization: Bearer (Base64-encoded JSON). */ - @GetMapping("/bearer") - public ResponseEntity> demonstrateBearerAuth( - Authentication authentication, HttpServletRequest request) { - Map result = SampleAuthUtils.authBase("Bearer Token", authentication); - - String authHeader = request.getHeader("Authorization"); - result.put("authorizationHeader", authHeader != null ? authHeader : "Not provided"); - result.put( - "note", "Bearer token is Base64-encoded JSON with access-token, client, uid, expiry"); - - return ResponseEntity.ok(result); - } - - /** General info endpoint that works with any authentication method. */ - @GetMapping("/info") - public ResponseEntity> getAuthInfo( - Authentication authentication, HttpServletRequest request) { - Map result = SampleAuthUtils.authBase(authentication); - - List authorities = - authentication != null - ? authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList() - : List.of(); - result.put("authorities", authorities); - result.put("authMethod", SampleAuthUtils.detectAuthMethod(request)); - result.put( - "message", "This endpoint accepts authentication via headers, cookies, or Bearer token"); - - return ResponseEntity.ok(result); - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/HealthController.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/HealthController.java deleted file mode 100644 index 63eb935..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/HealthController.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.controller; - -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api") -public class HealthController { - - @GetMapping("/health") - public ResponseEntity> health() { - return ResponseEntity.ok(Map.of("status", "UP")); - } - - @GetMapping("/me") - public ResponseEntity> me(Authentication authentication) { - return ResponseEntity.ok( - Map.of( - "authenticated", - authentication != null && authentication.isAuthenticated(), - "principal", - authentication != null ? authentication.getName() : "anonymous")); - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/TestController.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/TestController.java deleted file mode 100644 index 5752e8e..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/controller/TestController.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.controller; - -import com.quantipixels.ogiri.samples.java.repository.SampleTokenRepository; -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory; -import jakarta.servlet.http.HttpServletRequest; -import java.time.Instant; -import java.util.Map; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Test-only endpoints for exercising expiration flows in the sample app. - * - *

Not for production use. Exposes helpers that manipulate token state directly so the frontend - * can drive the full expiry → 401 → redirect cycle without waiting for a real TTL to elapse. - */ -@RestController -@RequestMapping("/api/test") -@Profile("!jdbc") -public class TestController { - - private final SampleTokenRepository tokenRepository; - private final OgiriUserDirectory userDirectory; - - public TestController(SampleTokenRepository tokenRepository, OgiriUserDirectory userDirectory) { - this.tokenRepository = tokenRepository; - this.userDirectory = userDirectory; - } - - /** - * Backdates the current session's {@code expiryAt} to one hour in the past so the next - * authenticated request returns 401. - * - *

Requires a valid session (Ogiri filter must authenticate the request before this method - * runs). The {@code client} header identifies the session to expire. - */ - @PostMapping("/expire-token") - public ResponseEntity> expireToken( - Authentication authentication, HttpServletRequest request) { - if (authentication == null || !authentication.isAuthenticated()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "Not authenticated")); - } - - var user = userDirectory.findByUsername(authentication.getName()); - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "User not found")); - } - - var client = request.getHeader("client"); - if (client == null || client.isBlank()) { - return ResponseEntity.badRequest().body(Map.of("message", "Missing client header")); - } - - var token = tokenRepository.findByUserIdAndClient(user.getOgiriUserId(), client); - if (token.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("message", "Session not found")); - } - - var entity = token.get(); - entity.setExpiryAt(Instant.now().minusSeconds(3600)); - tokenRepository.save(entity); - - return ResponseEntity.ok(Map.of("message", "Token expired")); - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/entity/SampleToken.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/entity/SampleToken.java deleted file mode 100644 index 65c7c6c..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/entity/SampleToken.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.entity; - -import com.quantipixels.ogiri.jpa.OgiriBaseTokenEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.Index; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; - -/** - * Sample JPA Token entity extending OgiriBaseTokenEntity. - * - *

All token fields and JPA annotations are inherited from OgiriBaseTokenEntity. This class only - * needs {@code @Entity} and {@code @Table} with indexes and constraints. - */ -@Entity -@Table( - name = "user_tokens", - indexes = { - @Index(name = "idx_user_tokens_user_id", columnList = "user_id"), - @Index(name = "idx_user_tokens_expiry", columnList = "expiry_at"), - }, - uniqueConstraints = { - @UniqueConstraint( - name = "uk_user_tokens_user_client", - columnNames = {"user_id", "client"}), - }) -public class SampleToken extends OgiriBaseTokenEntity {} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleToken.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleToken.java deleted file mode 100644 index d0cdc12..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleToken.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.jdbc; - -import com.quantipixels.ogiri.jdbc.OgiriBaseTokenRow; -import java.time.Instant; - -/** - * Sample JDBC token for the Java sample. - * - *

Subclasses {@link OgiriBaseTokenRow} — no annotations required. You can also skip subclassing - * and use {@link OgiriBaseTokenRow} directly if you have no custom fields to add. - */ -public class JdbcSampleToken extends OgiriBaseTokenRow { - - public JdbcSampleToken() { - super(0L, 0L, "", "", "app", Instant.now(), Instant.now(), Instant.now(), Instant.now()); - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepository.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepository.java deleted file mode 100644 index e040c61..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepository.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.jdbc; - -import com.quantipixels.ogiri.jdbc.OgiriJdbcTokenRepository; -import java.sql.Timestamp; -import org.springframework.context.annotation.Profile; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.simple.JdbcClient; -import org.springframework.stereotype.Repository; - -/** - * JDBC repository for JdbcSampleToken. - * - *

Extend {@link OgiriJdbcTokenRepository} and provide: - * - *

    - *
  • {@link #tableName()}: the target table (must match your schema) - *
  • {@link #rowMapper()}: maps a ResultSet row to your token class - *
- * - *

All 15 OgiriTokenRepository methods are handled by the base class via JdbcClient. - */ -@Repository -@Profile("jdbc") -public class JdbcSampleTokenRepository extends OgiriJdbcTokenRepository { - - public JdbcSampleTokenRepository(JdbcClient jdbcClient) { - super(jdbcClient); - } - - @Override - public String tableName() { - return "user_tokens"; - } - - @Override - public RowMapper rowMapper() { - return (rs, rowNum) -> { - JdbcSampleToken token = new JdbcSampleToken(); - token.setId(rs.getLong("id")); - token.setUserId(rs.getLong("user_id")); - token.setClient(rs.getString("client")); - token.setToken(rs.getString("token_hash")); - token.setTokenType(rs.getString("token_type")); - token.setTokenSubtype(rs.getString("token_subtype")); - token.setExpiryAt(rs.getTimestamp("expiry_at").toInstant()); - token.setPreviousToken(rs.getString("previous_token_hash")); - token.setLastToken(rs.getString("last_token_hash")); - token.setTokenUpdatedAt(rs.getTimestamp("token_updated_at").toInstant()); - Timestamp lastUsedAt = rs.getTimestamp("last_used_at"); - token.setLastUsedAt(lastUsedAt != null ? lastUsedAt.toInstant() : null); - token.setCreatedAt(rs.getTimestamp("created_at").toInstant()); - token.setUpdatedAt(rs.getTimestamp("updated_at").toInstant()); - return token; - }; - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenService.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenService.java deleted file mode 100644 index 0e43ce7..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenService.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.jdbc; - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties; -import com.quantipixels.ogiri.security.core.IdentifierPolicy; -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory; -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry; -import com.quantipixels.ogiri.security.tokens.OgiriTokenService; -import com.quantipixels.ogiri.security.tokens.OgiriTokenType; -import java.time.Instant; -import org.springframework.context.annotation.Profile; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -/** - * Token service for the JDBC-backed Java sample. - * - *

Active only when the "jdbc" Spring profile is enabled. The companion JPA-backed {@link - * com.quantipixels.ogiri.samples.java.service.SampleTokenService} is excluded via - * {@code @Profile("!jdbc")}, so only one OgiriTokenService bean exists at runtime. - * - *

Optional extension points (audit hook, rate-limit hook, lookup cache) are wired automatically - * by the ogiri auto-configuration via setter injection when the corresponding beans are present in - * the application context. - * - *

Run with: {@code --spring.profiles.active=jdbc} - */ -@Service -@Profile("jdbc") -public class JdbcSampleTokenService extends OgiriTokenService { - - public JdbcSampleTokenService( - JdbcSampleTokenRepository tokenRepository, - PasswordEncoder passwordEncoder, - OgiriUserDirectory userDirectory, - IdentifierPolicy identifierPolicy, - OgiriSubTokenRegistry subTokenRegistry, - OgiriConfigurationProperties properties) { - super( - tokenRepository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties); - } - - @Override - protected JdbcSampleToken tokenFactory( - long userId, - String client, - String hashedToken, - OgiriTokenType tokenType, - Instant expiry, - String tokenSubtype, - String plainTokenValue) { - JdbcSampleToken token = new JdbcSampleToken(); - token.setUserId(userId); - token.setClient(client); - token.setToken(hashedToken); - token.setTokenType(tokenType.getLabel()); - token.setExpiryAt(expiry); - token.setTokenSubtype(tokenSubtype); - token.setPlainToken(plainTokenValue); - return token; - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepository.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepository.java deleted file mode 100644 index a8f4d5e..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepository.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.repository; - -import com.quantipixels.ogiri.samples.java.entity.SampleToken; -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository; -import java.time.Instant; -import java.util.Collection; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -/** - * Repository for SampleToken using the simplified pattern. - * - *

Since 1.3.1, OgiriTokenRepository method names follow Spring Data conventions, so Spring Data - * automatically generates most query implementations. No adapter class needed! - * - *

For methods that Spring Data cannot auto-generate (bulk deletes), we provide explicit @Query - * annotations. - */ -@Repository -public interface SampleTokenRepository - extends JpaRepository, OgiriTokenRepository { - - // Explicit override to resolve method ambiguity between JpaRepository and OgiriTokenRepository - // These are automatically implemented by Spring Data JPA - @Override - S save(S entity); - - @Override - void delete(SampleToken entity); - - @Override - void deleteById(Long id); - - // Spring Data auto-generates these based on method naming conventions: - // - findByUserIdOrderByUpdatedAtDesc(userId) - // - findByUserIdAndClient(userId, client) -> Optional - // - findByUserIdAndClientIn(userId, clients) -> List (for batch sub-token loading) - // - findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(userId, tokenSubtype) - // - findByExpiryAtBefore(cutoff) - // - findByTokenType(tokenType) - - // Bulk delete operations need explicit @Query (Spring Data naming convention doesn't support IN - // clause) - @Override - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.userId = ?1 AND t.client = ?2") - void deleteByUserIdAndClient(long userId, String client); - - @Override - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.userId = ?1 AND t.client IN ?2") - void deleteByUserIdAndClientIn(long userId, Collection clients); - - @Override - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.userId = ?1") - void deleteByUserId(long userId); - - // Optional performance override for bulk delete - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.expiryAt < ?1") - int deleteByExpiryAtBefore(Instant cutoff); - - // Optional performance override for count (uses COUNT instead of loading all) - @Override - @Transactional(readOnly = true) - @Query("SELECT COUNT(t) FROM SampleToken t WHERE t.userId = ?1") - long countByUserId(long userId); -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleOgiriUserDirectory.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleOgiriUserDirectory.java deleted file mode 100644 index 00b94ad..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleOgiriUserDirectory.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.security; - -import com.quantipixels.ogiri.security.spi.OgiriUser; -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; - -/** - * Sample OgiriUserDirectory implementation for Java. - * - *

In a real application, this would load users from a database. This sample uses an in-memory - * map for demonstration. - */ -@Component -public class SampleOgiriUserDirectory implements OgiriUserDirectory { - - private final Map usersById = new HashMap<>(); - private final Map usersByUsername = new HashMap<>(); - - public SampleOgiriUserDirectory(PasswordEncoder passwordEncoder) { - String encodedPassword = passwordEncoder.encode("password"); - SampleUser user1 = new SampleUser(1L, "user1", encodedPassword, "user1@example.com"); - SampleUser user2 = new SampleUser(2L, "user2", encodedPassword, "user2@example.com"); - usersById.put(1L, user1); - usersById.put(2L, user2); - usersByUsername.put("user1", user1); - usersByUsername.put("user2", user2); - } - - @Override - public OgiriUser loadUserByUsername(String username) { - OgiriUser user = usersByUsername.get(username); - if (user == null) { - throw new IllegalArgumentException("User not found: " + username); - } - return user; - } - - public OgiriUser findById(long id) { - return usersById.get(id); - } - - public OgiriUser findByEmail(String email) { - return usersById.values().stream() - .filter(u -> u.getEmail().equals(email)) - .findFirst() - .orElse(null); - } - - public OgiriUser findByUsername(String username) { - return usersByUsername.get(username); - } - - public void recordSuccessfulLogin(long userId) { - // In a real application, update user last_login_at timestamp - } - - /** Sample user implementation. */ - public static class SampleUser implements OgiriUser { - private final long userId; - private final String username; - private final String password; - private final String email; - - public SampleUser(Long userId, String username, String password, String email) { - this.userId = userId; - this.username = username; - this.password = password; - this.email = email; - } - - public String getEmail() { - return email; - } - - @Override - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_USER")); - } - - @Override - public String getPassword() { - return password; - } - - @Override - public String getUsername() { - return username; - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } - - @Override - public long getOgiriUserId() { - return userId; - } - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleRouteRegistry.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleRouteRegistry.java deleted file mode 100644 index 6bc6031..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/security/SampleRouteRegistry.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.security; - -import com.quantipixels.ogiri.security.routes.OgiriRoute; -import com.quantipixels.ogiri.security.routes.OgiriRouteRegistry; -import java.util.List; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; - -/** - * Sample RouteRegistry implementation for Java. - * - *

Declares public/unauthenticated routes that the filter should allow. - */ -@Component -public class SampleRouteRegistry implements OgiriRouteRegistry { - - @Override - public List routes() { - return List.of( - new OgiriRoute(HttpMethod.POST, "/api/auth/login", true, false, null), - new OgiriRoute(HttpMethod.GET, "/api/health", true, false, null), - new OgiriRoute(HttpMethod.GET, "/api/docs/**", true, false, null)); - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/service/SampleTokenService.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/service/SampleTokenService.java deleted file mode 100644 index 622f08c..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/service/SampleTokenService.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.service; - -import com.quantipixels.ogiri.samples.java.entity.SampleToken; -import com.quantipixels.ogiri.samples.java.repository.SampleTokenRepository; -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties; -import com.quantipixels.ogiri.security.core.IdentifierPolicy; -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory; -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry; -import com.quantipixels.ogiri.security.tokens.OgiriTokenService; -import com.quantipixels.ogiri.security.tokens.OgiriTokenType; -import java.time.Instant; -import org.springframework.context.annotation.Profile; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -/** - * Sample TokenService implementation for the Java example app. - * - *

Demonstrates the minimal subclassing pattern: extend {@link OgiriTokenService}, inject the six - * required collaborators, and override {@link #tokenFactory} to instantiate the custom token class. - * Optional extension points (audit hook, rate-limit hook, lookup cache) are wired automatically by - * the ogiri auto-configuration via setter injection when the corresponding beans are present in the - * application context. - */ -@Service -@Profile("!jdbc") -public class SampleTokenService extends OgiriTokenService { - - public SampleTokenService( - SampleTokenRepository tokenRepository, - PasswordEncoder passwordEncoder, - OgiriUserDirectory userDirectory, - IdentifierPolicy identifierPolicy, - OgiriSubTokenRegistry subTokenRegistry, - OgiriConfigurationProperties properties) { - super( - tokenRepository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties); - } - - @Override - protected SampleToken tokenFactory( - long userId, - String client, - String hashedToken, - OgiriTokenType tokenType, - Instant expiry, - String tokenSubtype, - String plainTokenValue) { - SampleToken token = new SampleToken(); - token.setUserId(userId); - token.setClient(client); - token.setToken(hashedToken); - token.setTokenType(tokenType.name()); - token.setExpiryAt(expiry); - token.setTokenSubtype(tokenSubtype); - token.setPlainToken(plainTokenValue); - return token; - } -} diff --git a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/util/SampleAuthUtils.java b/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/util/SampleAuthUtils.java deleted file mode 100644 index b7295de..0000000 --- a/sample/sample-java/src/main/java/com/quantipixels/ogiri/samples/java/util/SampleAuthUtils.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.util; - -import jakarta.servlet.http.HttpServletRequest; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import org.springframework.security.core.Authentication; - -/** Utility methods shared across demo controllers. */ -public final class SampleAuthUtils { - - private SampleAuthUtils() {} - - /** Detects which Ogiri authentication method was used in the request. */ - public static String detectAuthMethod(HttpServletRequest request) { - String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - return "Bearer Token"; - } - if (request.getCookies() != null - && Arrays.stream(request.getCookies()) - .anyMatch(cookie -> "access-token".equals(cookie.getName()))) { - return "Cookie"; - } - if (request.getHeader("access-token") != null) { - return "Header"; - } - return "None"; - } - - /** Builds a base response map with the caller's authentication state. */ - public static Map authBase(Authentication authentication) { - Map map = new HashMap<>(); - map.put("authenticated", authentication != null && authentication.isAuthenticated()); - map.put("principal", authentication != null ? authentication.getName() : "anonymous"); - return map; - } - - /** Builds a base response map including the named authentication method. */ - public static Map authBase(String method, Authentication authentication) { - Map map = authBase(authentication); - map.put("method", method); - return map; - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/FullCycleHttpTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/FullCycleHttpTest.java deleted file mode 100644 index 6b85b4f..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/FullCycleHttpTest.java +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java; - -import static org.junit.jupiter.api.Assertions.*; - -import com.quantipixels.ogiri.samples.java.repository.SampleTokenRepository; -import java.time.Instant; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.test.context.ActiveProfiles; - -/** - * Full HTTP cycle test — Java sample app. - * - *

Starts a real server on a random port and drives the complete auth cycle via TestRestTemplate: - * login → rotation → stale-token rejection → logout → post-logout rejection. - * - *

Configuration overrides: - * - *

    - *
  • register-filter=true — re-enables the Ogiri security filter (disabled in the base test - * profile) - *
  • rotate-stale-seconds=0 — forces rotation on every request (skips the shouldRotate() guard) - *
  • batch-grace-seconds=0 — disables the batch window so rotation is never suppressed - *
  • cookies.enabled=false — tests header-only auth; avoids cookie complexity over plain HTTP - *
- * - *

Note: {@code @Transactional} does not roll back across RANDOM_PORT tests because the HTTP - * server runs in a separate thread. Cleanup is performed in {@code @BeforeEach} instead. - */ -@SpringBootTest( - webEnvironment = WebEnvironment.RANDOM_PORT, - properties = { - "ogiri.security.register-filter=true", - "ogiri.auth.rotate-stale-seconds=0", - "ogiri.auth.batch-grace-seconds=0", - "ogiri.cookies.enabled=false", - }) -@ActiveProfiles("test") -class FullCycleHttpTest { - - @Autowired private TestRestTemplate rest; - @Autowired private SampleTokenRepository tokenRepository; - - @BeforeEach - void clean() { - tokenRepository.deleteAll(); - } - - @Test - void fullAuthCycle_login_rotation_staleRejection_logout() { - // ── 1. Login ────────────────────────────────────────────────────────────────────── - var loginBody = Map.of("username", "user1@example.com", "password", "password"); - var loginResponse = rest.postForEntity("/api/auth/login", loginBody, Map.class); - assertEquals(200, loginResponse.getStatusCode().value(), "Login should succeed"); - - var token0 = loginResponse.getHeaders().getFirst("access-token"); - var client = loginResponse.getHeaders().getFirst("client"); - var uid = loginResponse.getHeaders().getFirst("uid"); - var expiry = loginResponse.getHeaders().getFirst("expiry"); - assertNotNull(token0, "Login must return access-token header"); - assertNotNull(client, "Login must return client header"); - assertNotNull(uid, "Login must return uid header"); - assertNotNull(expiry, "Login must return expiry header"); - - // ── 2. First authenticated request — filter rotates the token ───────────────── - var r1 = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - new HttpEntity<>(authHeaders(token0, client, uid, expiry)), - Map.class); - assertEquals(200, r1.getStatusCode().value(), "First authenticated request should succeed"); - - var token1 = r1.getHeaders().getFirst("access-token"); - assertNotNull(token1, "Filter must return rotated token in response headers"); - assertNotEquals(token0, token1, "Rotated token must differ from the login token"); - - // ── 3. Original token is stale — must be rejected ───────────────────────────── - var stale = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - new HttpEntity<>(authHeaders(token0, client, uid, expiry)), - Map.class); - assertEquals(401, stale.getStatusCode().value(), "Stale token must be rejected after rotation"); - - // ── 4. Rotated token is accepted and produces another rotation ──────────────── - var r2 = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - new HttpEntity<>(authHeaders(token1, client, uid, expiry)), - Map.class); - assertEquals(200, r2.getStatusCode().value(), "Rotated token should be accepted"); - var token2 = r2.getHeaders().getFirst("access-token"); - assertNotNull(token2); - - // ── 5. Logout with the current token ────────────────────────────────────────── - var logout = - rest.exchange( - "/api/auth/logout", - HttpMethod.POST, - new HttpEntity<>(authHeaders(token2, client, uid, expiry)), - Map.class); - assertEquals(200, logout.getStatusCode().value(), "Logout should succeed"); - - // ── 6. Session is fully invalidated — any token rejected ────────────────────── - var postLogout = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - new HttpEntity<>(authHeaders(token2, client, uid, expiry)), - Map.class); - assertEquals( - 401, postLogout.getStatusCode().value(), "Session must be invalidated after logout"); - } - - @Test - void expiredToken_isRejected() { - // ── 1. Login ────────────────────────────────────────────────────────────────────── - var loginBody = Map.of("username", "user1@example.com", "password", "password"); - var loginResponse = rest.postForEntity("/api/auth/login", loginBody, Map.class); - assertEquals(200, loginResponse.getStatusCode().value(), "Login should succeed"); - - var token0 = loginResponse.getHeaders().getFirst("access-token"); - var client = loginResponse.getHeaders().getFirst("client"); - var uid = loginResponse.getHeaders().getFirst("uid"); - var expiry = loginResponse.getHeaders().getFirst("expiry"); - assertNotNull(token0, "Login must return access-token header"); - assertNotNull(client, "Login must return client header"); - assertNotNull(uid, "Login must return uid header"); - assertNotNull(expiry, "Login must return expiry header"); - - // ── 2. Confirm the token is accepted before expiry ──────────────────────────── - var r1 = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - new HttpEntity<>(authHeaders(token0, client, uid, expiry)), - Map.class); - assertEquals(200, r1.getStatusCode().value(), "Request before expiry should succeed"); - - // The filter rotates the token on every request (rotate-stale-seconds=0), so use - // the rotated token for subsequent requests. - var currentToken = - r1.getHeaders().getFirst("access-token") != null - ? r1.getHeaders().getFirst("access-token") - : token0; - - // ── 3. Backdate the token's expiryAt in the DB ──────────────────────────────── - var entity = - tokenRepository.findAll().stream() - .filter(t -> client.equals(t.getClient())) - .findFirst() - .orElseThrow(() -> new AssertionError("Session must exist in the DB")); - entity.setExpiryAt(Instant.now().minusSeconds(3600)); - tokenRepository.save(entity); - - // ── 4. Expired token must be rejected with 401 ──────────────────────────────── - var expired = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - new HttpEntity<>(authHeaders(currentToken, client, uid, expiry)), - Map.class); - assertEquals(401, expired.getStatusCode().value(), "Expired token must be rejected"); - } - - // ── helpers ────────────────────────────────────────────────────────────────────────────── - - private HttpHeaders authHeaders(String accessToken, String client, String uid, String expiry) { - var headers = new HttpHeaders(); - headers.set("access-token", accessToken); - headers.set("client", client); - headers.set("uid", uid); - headers.set("expiry", expiry); - return headers; - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/TokenServiceIntegrationTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/TokenServiceIntegrationTest.java deleted file mode 100644 index f91dcd1..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/TokenServiceIntegrationTest.java +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java; - -import static com.quantipixels.ogiri.security.core.AuthHeaderKt.ACCESS_TOKEN; -import static com.quantipixels.ogiri.security.core.AuthHeaderKt.CLIENT; -import static com.quantipixels.ogiri.security.core.AuthHeaderKt.EXPIRY; -import static com.quantipixels.ogiri.security.core.AuthHeaderKt.UID; -import static org.junit.jupiter.api.Assertions.*; - -import com.quantipixels.ogiri.samples.java.entity.SampleToken; -import com.quantipixels.ogiri.samples.java.repository.SampleTokenRepository; -import com.quantipixels.ogiri.samples.java.service.SampleTokenService; -import com.quantipixels.ogiri.security.core.SecurityServiceException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@SpringBootTest -@ActiveProfiles("test") -@Transactional -class TokenServiceIntegrationTest { - - @Autowired private SampleTokenService tokenService; - @Autowired private SampleTokenRepository tokenRepository; - - private static final Long TEST_USER_ID = 1L; - private static final String TEST_EMAIL = "user1@example.com"; - private static final String TEST_PASSWORD = "password"; - - @BeforeEach - void setUp() { - tokenRepository.deleteAll(); - SecurityContextHolder.clearContext(); - } - - @Test - void createNewAuthToken_withNullClient_generatesClientAndPersistsAppToken() { - var authHeader = tokenService.createNewAuthToken(TEST_USER_ID, null, null); - - assertNotNull(authHeader.getAccessToken()); - assertNotNull(authHeader.getClient()); - assertEquals("user1", authHeader.getUid()); - - var savedToken = - tokenRepository.findByUserIdAndClient(TEST_USER_ID, authHeader.getClient()).orElse(null); - assertNotNull(savedToken); - assertEquals(TEST_USER_ID, savedToken.getUserId()); - assertEquals(authHeader.getClient(), savedToken.getClient()); - assertEquals("APP", savedToken.getTokenType()); - } - - @Test - void verifyUser_authenticatesAndAppendsAuthHeaders() { - var request = new MockHttpServletRequest("POST", "/api/auth/login"); - request.setRemoteAddr("127.0.0.1"); - var response = new MockHttpServletResponse(); - - tokenService.verifyUser(request, response, TEST_EMAIL, TEST_PASSWORD); - - var authentication = SecurityContextHolder.getContext().getAuthentication(); - assertNotNull(authentication); - assertEquals("user1", authentication.getName()); - assertNotNull(response.getHeader(ACCESS_TOKEN)); - assertNotNull(response.getHeader(CLIENT)); - assertEquals("user1", response.getHeader(UID)); - assertNotNull(response.getHeader(EXPIRY)); - } - - @Test - void verifyUser_rejectsInvalidCredentialsWithoutCreatingAuthContext() { - var request = new MockHttpServletRequest("POST", "/api/auth/login"); - request.setRemoteAddr("127.0.0.1"); - var response = new MockHttpServletResponse(); - - assertThrows( - SecurityServiceException.class, - () -> tokenService.verifyUser(request, response, TEST_EMAIL, "wrong-password")); - assertNull(SecurityContextHolder.getContext().getAuthentication()); - assertEquals(0, tokenRepository.findByUserIdOrderByUpdatedAtDesc(TEST_USER_ID).size()); - } - - @Test - void createNewAuthToken_rotatesTokenForSameClientWhileKeepingSinglePersistedRow() { - var first = tokenService.createNewAuthToken(TEST_USER_ID, "web", null); - var second = tokenService.createNewAuthToken(TEST_USER_ID, "web", null); - - assertNotEquals(first.getAccessToken(), second.getAccessToken()); - var webTokens = - tokenRepository.findByUserIdOrderByUpdatedAtDesc(TEST_USER_ID).stream() - .filter(token -> "web".equals(token.getClient())) - .toList(); - assertEquals(1, webTokens.size()); - } - - @Test - void deleteToken_removesOnlyTargetedClientTokenForSameUser() { - tokenService.createNewAuthToken(TEST_USER_ID, "mobile", null); - tokenService.createNewAuthToken(TEST_USER_ID, "web", null); - - tokenService.deleteToken(TEST_USER_ID, "mobile"); - - assertNull(tokenRepository.findByUserIdAndClient(TEST_USER_ID, "mobile").orElse(null)); - assertNotNull(tokenRepository.findByUserIdAndClient(TEST_USER_ID, "web").orElse(null)); - } - - @Test - void revokeClient_removesTokenForClientRepresentedByHeaders() { - var issued = tokenService.createNewAuthToken(TEST_USER_ID, "mobile", null); - SampleToken savedToken = - tokenRepository.findByUserIdAndClient(TEST_USER_ID, "mobile").orElse(null); - assertNotNull(savedToken); - - var request = new MockHttpServletRequest("POST", "/api/auth/logout"); - request.addHeader(ACCESS_TOKEN, issued.getAccessToken()); - request.addHeader(CLIENT, issued.getClient()); - request.addHeader(UID, issued.getUid()); - request.addHeader(EXPIRY, issued.getExpiry()); - var response = new MockHttpServletResponse(); - - tokenService.revokeClient(TEST_USER_ID, request, response); - - assertNull(tokenRepository.findByUserIdAndClient(TEST_USER_ID, "mobile").orElse(null)); - assertEquals(issued.getAccessToken(), response.getHeader(ACCESS_TOKEN)); - assertEquals("mobile", response.getHeader(CLIENT)); - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/V4QuickstartTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/V4QuickstartTest.java deleted file mode 100644 index 1bd7695..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/V4QuickstartTest.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.quantipixels.ogiri.session.HmacSha256TokenHasher; -import com.quantipixels.ogiri.session.OgiriSessions; -import com.quantipixels.ogiri.session.OpaqueTokenCodec; -import com.quantipixels.ogiri.session.SessionManager; -import com.quantipixels.ogiri.test.InMemorySessionStore; -import java.time.Clock; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class V4QuickstartTest { - @Test - void documentedV4CoreFlowRoundTrips() { - OpaqueTokenCodec codec = new OpaqueTokenCodec(); - SessionManager sessions = - new SessionManager( - new InMemorySessionStore(), - codec, - new HmacSha256TokenHasher("primary", Map.of("primary", new byte[32])), - subject -> true, - Clock.systemUTC()); - var issued = - sessions.issue( - OgiriSessions.subject("users", "opaque-user-id", "tenant-a"), - OgiriSessions.client("browser-id", "Work laptop")); - - var authenticated = sessions.authenticate(issued.getCredential().encoded(codec)); - - assertEquals("opaque-user-id", authenticated.getSubject().subjectValue()); - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/controller/HealthControllerTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/controller/HealthControllerTest.java deleted file mode 100644 index 5bcdf23..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/controller/HealthControllerTest.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.controller; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -@SpringBootTest -@AutoConfigureMockMvc(addFilters = false) -@ActiveProfiles("test") -class HealthControllerTest { - - @Autowired private MockMvc mockMvc; - - @Test - void healthEndpoint_returnsUpStatus() throws Exception { - mockMvc - .perform(get("/api/health").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value("UP")); - } - - @Test - void meEndpoint_withoutAuthentication_returnsAnonymous() throws Exception { - mockMvc - .perform(get("/api/me").accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.authenticated").value(false)) - .andExpect(jsonPath("$.principal").value("anonymous")); - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepositoryTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepositoryTest.java deleted file mode 100644 index 44c5213..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/jdbc/JdbcSampleTokenRepositoryTest.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.jdbc; - -import static org.junit.jupiter.api.Assertions.*; - -import com.quantipixels.ogiri.samples.java.Application; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) -@ActiveProfiles("jdbc") -class JdbcSampleTokenRepositoryTest { - - @Autowired private JdbcSampleTokenRepository tokenRepository; - - private static final long TEST_USER_ID = 100L; - private static final String TEST_CLIENT = "test-client"; - private static final String TEST_TOKEN = "hashed-token-123"; - - @BeforeEach - void setUp() { - tokenRepository.deleteByUserId(TEST_USER_ID); - tokenRepository.deleteByUserId(101L); - tokenRepository.deleteByUserId(102L); - } - - @Test - void shouldSaveAndRetrieveTokenByUserAndClient() { - JdbcSampleToken token = createToken(TEST_USER_ID, TEST_CLIENT, TEST_TOKEN); - tokenRepository.save(token); - - Optional retrieved = - tokenRepository.findByUserIdAndClient(TEST_USER_ID, TEST_CLIENT); - - assertTrue(retrieved.isPresent()); - assertEquals(TEST_USER_ID, retrieved.get().getUserId()); - assertEquals(TEST_CLIENT, retrieved.get().getClient()); - assertEquals(TEST_TOKEN, retrieved.get().getToken()); - } - - @Test - void shouldReturnEmptyOptionalForNonExistentToken() { - Optional retrieved = tokenRepository.findByUserIdAndClient(999L, "none"); - assertFalse(retrieved.isPresent()); - } - - @Test - void shouldFindAllTokensForUserAndExcludeOtherUsers() { - tokenRepository.save(createToken(TEST_USER_ID, "client-1", "token-1")); - tokenRepository.save(createToken(TEST_USER_ID, "client-2", "token-2")); - tokenRepository.save(createToken(101L, "client-3", "token-3")); - - List tokens = tokenRepository.findByUserIdOrderByUpdatedAtDesc(TEST_USER_ID); - - assertEquals(2, tokens.size()); - List clients = tokens.stream().map(JdbcSampleToken::getClient).toList(); - assertTrue(clients.contains("client-1")); - assertTrue(clients.contains("client-2")); - } - - @Test - void shouldFindExpiredTokens() { - Instant now = Instant.now(); - - JdbcSampleToken expired = createToken(TEST_USER_ID, "expired-client", TEST_TOKEN); - expired.setExpiryAt(now.minus(1, ChronoUnit.HOURS)); - - JdbcSampleToken valid = createToken(TEST_USER_ID, "valid-client", TEST_TOKEN); - valid.setExpiryAt(now.plus(1, ChronoUnit.HOURS)); - - tokenRepository.save(expired); - tokenRepository.save(valid); - - List expiredTokens = tokenRepository.findByExpiryAtBefore(now); - - assertTrue(expiredTokens.stream().anyMatch(t -> t.getClient().equals("expired-client"))); - assertTrue(expiredTokens.stream().noneMatch(t -> t.getExpiryAt().isAfter(now))); - } - - @Test - void shouldDeleteTokenByUserAndClient() { - tokenRepository.save(createToken(TEST_USER_ID, TEST_CLIENT, TEST_TOKEN)); - - tokenRepository.deleteByUserIdAndClient(TEST_USER_ID, TEST_CLIENT); - - assertFalse(tokenRepository.findByUserIdAndClient(TEST_USER_ID, TEST_CLIENT).isPresent()); - } - - @Test - void shouldDeleteSelectedTokensByClientList() { - tokenRepository.save(createToken(102L, "client-1", "token-1")); - tokenRepository.save(createToken(102L, "client-2", "token-2")); - tokenRepository.save(createToken(102L, "client-3", "token-3")); - - tokenRepository.deleteByUserIdAndClientIn(102L, List.of("client-1", "client-2")); - - List remaining = tokenRepository.findByUserIdOrderByUpdatedAtDesc(102L); - assertEquals(1, remaining.size()); - assertEquals("client-3", remaining.get(0).getClient()); - } - - private JdbcSampleToken createToken(long userId, String client, String tokenHash) { - JdbcSampleToken token = new JdbcSampleToken(); - token.setUserId(userId); - token.setClient(client); - token.setToken(tokenHash); - token.setExpiryAt(Instant.now().plus(1, ChronoUnit.HOURS)); - return token; - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepositoryTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepositoryTest.java deleted file mode 100644 index 67a17db..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/repository/SampleTokenRepositoryTest.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.repository; - -import static org.junit.jupiter.api.Assertions.*; - -import com.quantipixels.ogiri.samples.java.Application; -import com.quantipixels.ogiri.samples.java.entity.SampleToken; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) -@ActiveProfiles("test") -@Transactional -class SampleTokenRepositoryTest { - - @Autowired private SampleTokenRepository tokenRepository; - - private static final Long TEST_USER_ID = 1L; - private static final String TEST_CLIENT = "test-client"; - private static final String TEST_TOKEN = "hashed-token-123"; - - @BeforeEach - void setUp() { - tokenRepository.deleteAll(); - } - - @Test - void shouldSaveAndRetrieveTokenByUserAndClient() { - SampleToken token = createToken(TEST_USER_ID, TEST_CLIENT, TEST_TOKEN); - tokenRepository.save(token); - - Optional retrieved = - tokenRepository.findByUserIdAndClient(TEST_USER_ID, TEST_CLIENT); - - assertTrue(retrieved.isPresent()); - assertEquals(TEST_USER_ID, retrieved.get().getUserId()); - assertEquals(TEST_CLIENT, retrieved.get().getClient()); - assertEquals(TEST_TOKEN, retrieved.get().getToken()); - } - - @Test - void shouldReturnEmptyOptionalForNonExistentToken() { - Optional retrieved = tokenRepository.findByUserIdAndClient(999L, "non-existent"); - assertFalse(retrieved.isPresent()); - } - - @Test - void shouldFindAllTokensForUserAndExcludeTokensFromOtherUsers() { - SampleToken token1 = createToken(TEST_USER_ID, "client-1", "token-1"); - SampleToken token2 = createToken(TEST_USER_ID, "client-2", "token-2"); - SampleToken otherUserToken = createToken(2L, "client-3", "token-3"); - - tokenRepository.save(token1); - tokenRepository.save(token2); - tokenRepository.save(otherUserToken); - - List tokens = tokenRepository.findByUserIdOrderByUpdatedAtDesc(TEST_USER_ID); - - assertEquals(2, tokens.size()); - List clients = tokens.stream().map(SampleToken::getClient).toList(); - assertTrue(clients.contains("client-1")); - assertTrue(clients.contains("client-2")); - } - - @Test - void shouldFindExpiredTokens() { - Instant now = Instant.now(); - - SampleToken expiredToken = createToken(TEST_USER_ID, "expired-client", TEST_TOKEN); - expiredToken.setExpiryAt(now.minus(1, ChronoUnit.HOURS)); - - SampleToken validToken = createToken(TEST_USER_ID, "valid-client", TEST_TOKEN); - validToken.setExpiryAt(now.plus(1, ChronoUnit.HOURS)); - - tokenRepository.save(expiredToken); - tokenRepository.save(validToken); - - List expired = tokenRepository.findByExpiryAtBefore(now); - - assertEquals(1, expired.size()); - assertEquals("expired-client", expired.get(0).getClient()); - } - - @Test - void shouldDeleteTokenByUserAndClient() { - SampleToken token = createToken(TEST_USER_ID, TEST_CLIENT, TEST_TOKEN); - tokenRepository.save(token); - - tokenRepository.deleteByUserIdAndClient(TEST_USER_ID, TEST_CLIENT); - - Optional retrieved = - tokenRepository.findByUserIdAndClient(TEST_USER_ID, TEST_CLIENT); - assertFalse(retrieved.isPresent()); - } - - @Test - void shouldDeleteSelectedTokensByClientList() { - SampleToken token1 = createToken(TEST_USER_ID, "client-1", "token-1"); - SampleToken token2 = createToken(TEST_USER_ID, "client-2", "token-2"); - SampleToken token3 = createToken(TEST_USER_ID, "client-3", "token-3"); - - tokenRepository.save(token1); - tokenRepository.save(token2); - tokenRepository.save(token3); - - tokenRepository.deleteByUserIdAndClientIn(TEST_USER_ID, List.of("client-1", "client-2")); - - List remaining = tokenRepository.findByUserIdOrderByUpdatedAtDesc(TEST_USER_ID); - assertEquals(1, remaining.size()); - assertEquals("client-3", remaining.get(0).getClient()); - } - - private SampleToken createToken(Long userId, String client, String token) { - SampleToken sampleToken = new SampleToken(); - sampleToken.setUserId(userId); - sampleToken.setClient(client); - sampleToken.setToken(token); - sampleToken.setExpiryAt(Instant.now().plus(1, ChronoUnit.HOURS)); - return sampleToken; - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/SecurityConfigTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/SecurityConfigTest.java deleted file mode 100644 index de5079a..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/SecurityConfigTest.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.security; - -import static org.junit.jupiter.api.Assertions.*; - -import com.quantipixels.ogiri.security.routes.OgiriRoute; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpMethod; - -class SecurityConfigTest { - - private SampleRouteRegistry routeRegistry; - - @BeforeEach - void setUp() { - routeRegistry = new SampleRouteRegistry(); - } - - @Test - void routeRegistry_exposesExactPublicRoutesWithRateLimitingEnabled() { - List routes = routeRegistry.routes(); - Set> actual = new HashSet<>(); - for (OgiriRoute route : routes) { - actual.add( - List.of( - route.getMethod().name(), route.getPath(), route.getUseAuth(), route.getRateLimit())); - } - Set> expected = - Set.of( - List.of(HttpMethod.POST.name(), "/api/auth/login", false, true), - List.of(HttpMethod.GET.name(), "/api/health", false, true), - List.of(HttpMethod.GET.name(), "/api/docs/**", false, true)); - - assertEquals(expected, actual); - } -} diff --git a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/UserDirectoryTest.java b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/UserDirectoryTest.java deleted file mode 100644 index 05750b6..0000000 --- a/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/security/UserDirectoryTest.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.java.security; - -import static org.junit.jupiter.api.Assertions.*; - -import com.quantipixels.ogiri.security.spi.OgiriUser; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -class UserDirectoryTest { - - private SampleOgiriUserDirectory userDirectory; - private PasswordEncoder passwordEncoder; - - @BeforeEach - void setUp() { - passwordEncoder = new BCryptPasswordEncoder(); - userDirectory = new SampleOgiriUserDirectory(passwordEncoder); - } - - @Test - void loadUserByUsername_returnsUserForValidUsername() { - OgiriUser user = userDirectory.loadUserByUsername("user1"); - - assertNotNull(user); - assertEquals("user1", user.getUsername()); - assertEquals(1L, user.getOgiriUserId()); - } - - @Test - void loadUserByUsername_throwsExceptionForUnknownUsername() { - assertThrows( - IllegalArgumentException.class, () -> userDirectory.loadUserByUsername("nonexistent")); - } - - @Test - void findByEmail_resolvesKnownUserAndReturnsNullForUnknown() { - OgiriUser found = userDirectory.findByEmail("user1@example.com"); - OgiriUser missing = userDirectory.findByEmail("unknown@example.com"); - - assertNotNull(found); - assertEquals("user1", found.getUsername()); - assertNull(missing); - } - - @Test - void loadedUser_hasExpectedAuthoritiesAndEnabledFlags() { - OgiriUser user = userDirectory.loadUserByUsername("user1"); - - assertEquals(1, user.getAuthorities().size()); - assertTrue(user.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))); - assertTrue(user.isEnabled()); - assertTrue(user.isAccountNonExpired()); - assertTrue(user.isAccountNonLocked()); - assertTrue(user.isCredentialsNonExpired()); - } - - @Test - void recordSuccessfulLogin_isNoOpForSampleDirectory() { - assertDoesNotThrow(() -> userDirectory.recordSuccessfulLogin(1L)); - } -} diff --git a/sample/sample-kotlin/README.md b/sample/sample-kotlin/README.md deleted file mode 100644 index db574ff..0000000 --- a/sample/sample-kotlin/README.md +++ /dev/null @@ -1,386 +0,0 @@ -# Ògiri Security Kotlin Sample Application - -A complete example demonstrating how to integrate the **ogiri** token-based authentication library into a Spring Boot application using Kotlin. - -## Overview - -This sample application showcases: - -- Token-based authentication with rotating tokens -- Token rotation and batch grace windows -- Sub-token management (extensible for device-specific tokens, etc.) -- User authentication with Spring Security -- Route-based access control (public vs. authenticated endpoints) -- Database persistence with Spring Data JPA -- Kotlin idioms and coroutine-friendly patterns - -## Prerequisites - -- **Java 17+** -- **Kotlin 2.0+** -- **Gradle** (or use the provided Gradle wrapper) -- **PostgreSQL** (optional; H2 in-memory is default for quick setup) - -## Configuration - -### Default (In-Memory H2) - -The application runs with H2 in-memory database by default. This requires zero setup and is ideal for development and testing. - -The H2 console is available at `http://localhost:48081/h2-console` (leave username as `sa`, password blank). - -### PostgreSQL Setup (Optional) - -To use PostgreSQL instead: - -1. Create a PostgreSQL database: - -```bash -createdb ogiri_sample_kotlin -``` - -2. Create `src/main/resources/application-postgres.yml`: - -```yaml -spring: - datasource: - url: jdbc:postgresql://localhost:5432/ogiri_sample_kotlin - username: postgres - password: your_password - driver-class-name: org.postgresql.Driver - jpa: - hibernate: - ddl-auto: validate - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect -``` - -3. Create the schema using the SQL from `../../docs/` or Flyway migrations - -4. Run with the postgres profile: - -```bash -./gradlew :sample:sample-kotlin:bootRun --args='--spring.profiles.active=postgres' -``` - -### ogiri Security Configuration - -The library is auto-configured in `com.quantipixels.ogiri.samples.kotlin.config.SecurityConfig`. Key configuration properties: - -```yaml -ogiri: - auth: - max-clients: 24 # Max concurrent clients per user - batch-grace-seconds: 30 # Grace period for token batch requests - token-lifespan-days: 14 # Token expiration in days - security: - register-filter: true # Auto-register authentication filter -``` - -## Running the Application - -### Default (In-Memory H2) - -From the repository root: - -```bash -./gradlew :sample:sample-kotlin:bootRun -``` - -The application starts on `http://localhost:48081` with an in-memory H2 database. No database setup required. - -### With PostgreSQL - -First, follow the PostgreSQL setup steps above, then: - -```bash -./gradlew :sample:sample-kotlin:bootRun --args='--spring.profiles.active=postgres' -``` - -## API Endpoints - -### Public Endpoints (No Authentication Required) - -- **POST /api/auth/login** - Authenticate and obtain tokens - - ```bash - curl -X POST http://localhost:48081/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1","password":"password"}' \ - -v - ``` - - Response includes tokens in headers, cookies (if enabled), and body. - -- **GET /api/health** - Application health check - ```bash - curl http://localhost:48081/api/health - ``` - -### Secured Endpoints (Authentication Required) - -The sample demonstrates **three authentication methods**. All methods are functionally equivalent: - -#### Method 1: HTTP Headers - -```bash -curl http://localhost:48081/api/demo/headers \ - -H "access-token: " \ - -H "client: " \ - -H "uid: " \ - -H "expiry: " -``` - -#### Method 2: Secure Cookies - -```bash -# Login with cookie storage -curl -X POST http://localhost:48081/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1","password":"password"}' \ - -c cookies.txt - -# Use stored cookies -curl http://localhost:48081/api/demo/cookies -b cookies.txt -``` - -#### Method 3: Bearer Token - -```bash -# Extract Authorization header from login response -curl http://localhost:48081/api/demo/bearer \ - -H "Authorization: Bearer " -``` - -### Available Endpoints - -| Endpoint | Method | Auth | Description | -| ------------------------ | ------ | ---- | ------------------------------------ | -| `/api/health` | GET | No | Health check | -| `/api/me` | GET | Yes | Current user info | -| `/api/auth/login` | POST | No | Login and get tokens | -| `/api/auth/logout` | POST | Yes | Logout and revoke tokens | -| `/api/demo/headers` | GET | Yes | Test header-based auth | -| `/api/demo/cookies` | GET | Yes | Test cookie-based auth | -| `/api/demo/bearer` | GET | Yes | Test Bearer token auth | -| `/api/demo/info` | GET | Yes | General auth info | -| `/api/test/expire-token` | POST | Yes | Backdate token expiry (dev/test use) | - -### Test Users - -The sample includes two pre-configured users: - -| Username | Password | Email | -| -------- | -------- | ----------------- | -| user1 | password | user1@example.com | -| user2 | password | user2@example.com | - -### Complete Testing Flow - -```bash -# 1. Login and save response headers -curl -X POST http://localhost:48081/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"user1","password":"password"}' \ - -v 2>&1 | grep -E "< (access-token|client|uid|expiry|Authorization):" - -# 2. Extract tokens and test header auth -TOKEN="" -CLIENT="" -UID="" -EXPIRY="" - -curl http://localhost:48081/api/demo/headers \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $UID" \ - -H "expiry: $EXPIRY" - -# 3. Test general info endpoint -curl http://localhost:48081/api/demo/info \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $UID" \ - -H "expiry: $EXPIRY" - -# 4. Logout -curl -X POST http://localhost:48081/api/auth/logout \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $UID" \ - -H "expiry: $EXPIRY" -``` - -## Key Components - -### Entity - -- **SampleToken** - JPA entity extending `OgiriBaseToken` - -### Repository - -- **SampleTokenRepository** - Spring Data JPA + ogiri `OgiriTokenRepository` interface - -### Security - -- **SampleOgiriUserDirectory** - Implements `OgiriUserDirectory` for user lookup -- **SampleRouteRegistry** - Declares public routes via `OgiriRouteRegistry` -- **SecurityConfig** - Spring Security configuration - -### Service - -- **SampleTokenService** - Extends ogiri `OgiriTokenService` with custom token factory - -### Test Utilities - -- **TestController** - Exposes `POST /api/test/expire-token` to backdate the current session's expiry, enabling the full expiry → 401 → redirect flow without waiting for a real TTL (`@Profile("!jdbc")`) - -## Development - -### Testing - -Run unit tests: - -```bash -./gradlew :sample:sample-kotlin:test -``` - -### Building - -Build the application JAR: - -```bash -./gradlew :sample:sample-kotlin:build -``` - -The JAR will be available at `build/libs/sample-kotlin-*.jar` - -### Debugging - -Enable debug logging by adding to `application.yml`: - -```yaml -logging: - level: - com.quantipixels.ogiri: DEBUG -``` - -## Project Structure - -```text -sample-kotlin/ -├── src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/ -│ ├── Application.kt # Entry point -│ ├── config/SecurityConfig.kt # Spring Security setup -│ ├── controller/HealthController.kt # REST endpoints -│ ├── entity/SampleToken.kt # JPA token entity -│ ├── repository/SampleTokenRepository.kt # Data access -│ ├── security/ # ogiri integration -│ │ ├── SampleRouteRegistry.kt -│ │ ├── SampleOgiriUserDirectory.kt -│ │ └── ... -│ └── service/SampleTokenService.kt # Token service -├── src/main/resources/ -│ ├── application.yml # Configuration -│ └── db/migration/ # Flyway migrations -├── build.gradle.kts # Gradle build config -└── README.md # This file -``` - -## Kotlin-Specific Patterns - -### Data Classes - -The sample uses Kotlin data classes for token entities, providing automatic `equals()`, `hashCode()`, and `toString()`: - -```kotlin -data class SampleToken(...) : OgiriBaseToken() -``` - -### Extension Functions - -Leverage Kotlin extension functions for cleaner API usage: - -```kotlin -// Example extension for token filtering -val appTokens = tokens.filterByTokenType(TokenType.APP) -``` - -### Scope Functions - -Use `apply`, `let`, and `also` for fluent configuration: - -```kotlin -SampleToken(...).apply { - plainToken = generatedToken -} -``` - -## Extending the Sample - -### Adding Custom Routes - -Modify `SampleRouteRegistry` to declare additional public routes: - -```kotlin -override fun routes() = listOf( - Route(HttpMethod.GET, "/api/docs/**", rateLimit = true, useAuth = false), - Route(HttpMethod.POST, "/api/custom", rateLimit = true, useAuth = false) -) -``` - -### Adding Sub-Tokens - -Implement `OgiriSubTokenRegistration` to create domain-specific tokens: - -```kotlin -@Bean -fun deviceToken(): OgiriSubTokenRegistration = object : OgiriSubTokenRegistration { - override val name = "device" - override val includeByDefault = true - - override fun clientIdFor(parentClientId: String) = "$parentClientId.device" - - override fun expiry(parentExpiry: Instant) = - parentExpiry.minus(1, ChronoUnit.HOURS) -} -``` - -## Troubleshooting - -### Using H2 In-Memory Database - -- Data is lost when the application stops (normal for in-memory) -- To persist data, switch to PostgreSQL following the setup steps above -- Access the H2 console at `http://localhost:48081/h2-console` to browse the schema - -### Database Connection Issues (PostgreSQL) - -- Ensure PostgreSQL is running on the configured host/port -- Verify database credentials in `application-postgres.yml` -- Check that the database exists and schema is initialized - -### Authentication Failures - -- Confirm token headers are sent with each request (access-token, client, uid, expiry) -- Verify token hasn't expired using the expiry header -- Check that user credentials match values in `SampleOgiriUserDirectory` - -### Token Rotation Issues - -- Token rotation only occurs outside the batch grace window -- By default, 30 second grace period allows requests within that window without rotation -- Adjust `batch-grace-seconds` to change this behavior - -## References - -- [ogiri Documentation](../../docs/) -- [Token Authentication Flow](../../docs/authentication.md) -- [Spring Boot with Kotlin](https://spring.io/guides/tutorials/spring-boot-kotlin/) -- [Kotlin Language Documentation](https://kotlinlang.org/docs/home.html) -- [Spring Security](https://spring.io/projects/spring-security) - -## License - -Apache License 2.0 - See [LICENSE](../../LICENSE) file for details diff --git a/sample/sample-kotlin/build.gradle.kts b/sample/sample-kotlin/build.gradle.kts deleted file mode 100644 index 0e015b7..0000000 --- a/sample/sample-kotlin/build.gradle.kts +++ /dev/null @@ -1,58 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - kotlin("plugin.spring") - kotlin("plugin.jpa") - id("io.spring.dependency-management") version libs.versions.dependencyManagement.get() - id("org.springframework.boot") version libs.versions.springBoot.get() -} - -group = "com.quantipixels.ogiri.samples" - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } -} - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } - jvmToolchain(17) -} - -dependencyManagement { - imports { - mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}") - } -} - -dependencies { - implementation(project(":ogiri-jpa")) - // ogiri-jpa transitively includes ogiri-core and spring-boot-starter-data-jpa - implementation(project(":ogiri-jdbc")) - implementation(project(":ogiri-caffeine")) - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - - // Database drivers - runtimeOnly("com.h2database:h2:2.4.240") - runtimeOnly("org.postgresql:postgresql:42.7.13") - - testImplementation(project(":ogiri-test")) - testImplementation("org.springframework.boot:spring-boot-starter-test") { - exclude(module = "mockito-core") - } - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { useJUnitPlatform() } - -tasks.bootRun { - @Suppress("UNCHECKED_CAST") - systemProperties = - System.getProperties() - .stringPropertyNames() - .associate { it to System.getProperty(it) } - .toMutableMap() as MutableMap -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/Application.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/Application.kt deleted file mode 100644 index daee902..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/Application.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication -import org.springframework.context.annotation.ComponentScan - -/** - * Spring Boot application demonstrating ogiri token-based authentication in Kotlin. - * - * This sample shows how to: - * - Integrate ogiri as a dependency in a Kotlin Spring Boot application - * - Configure required SPI implementations (OgiriUserDirectory, OgiriRouteRegistry) - * - Use the authentication filter and token service - */ -@SpringBootApplication -@ComponentScan( - basePackages = - [ - "com.quantipixels.ogiri.security", - "com.quantipixels.ogiri.samples.kotlin", - ], -) -class Application - -fun main(args: Array) { - runApplication(*args) -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/AuthController.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/AuthController.kt deleted file mode 100644 index a0962bb..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/AuthController.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.controller - -import com.quantipixels.ogiri.security.core.SecurityServiceException -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -/** - * Authentication controller demonstrating login and logout flows. - * - * This controller shows how to integrate with OgiriTokenService for authentication operations. - */ -@RestController -@RequestMapping("/api/auth") -class AuthController( - private val tokenService: OgiriTokenService<*>, - private val userDirectory: OgiriUserDirectory, -) { - /** - * Login endpoint that validates credentials and returns authentication tokens. - * - * On successful login, this endpoint: - * 1. Validates username and password - * 2. Creates authentication tokens (APP + sub-tokens) - * 3. Returns tokens in response headers AND body - * 4. Sets secure cookies if cookie config is enabled - * - * Example request: - * ```bash - * curl -X POST http://localhost:8080/api/auth/login \ - * -H "Content-Type: application/json" \ - * -d '{"username":"user1","password":"password"}' - * ``` - * - * Response includes: - * - Headers: access-token, client, uid, expiry, Authorization (Bearer) - * - Cookies: access-token, client, uid, expiry (if enabled) - * - Body: JSON with token details - * - * @param request Login credentials - * @param httpRequest HTTP request for context - * @param httpResponse HTTP response for setting headers/cookies - * @return Authentication response with token details - */ - @PostMapping("/login") - fun login( - @RequestBody request: LoginRequest, - httpRequest: HttpServletRequest, - httpResponse: HttpServletResponse, - ): ResponseEntity { - return try { - tokenService.verifyUser( - httpRequest, - httpResponse, - request.username, - request.password, - ) - - val accessToken = httpResponse.getHeader("access-token") - val client = httpResponse.getHeader("client") - val uid = httpResponse.getHeader("uid") - val expiry = httpResponse.getHeader("expiry") - - ResponseEntity.ok( - AuthResponse( - accessToken = accessToken ?: "", - client = client ?: "", - uid = uid ?: "", - expiry = expiry ?: "", - message = "Login successful", - ), - ) - } catch (e: SecurityServiceException) { - ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body( - AuthResponse( - accessToken = "", - client = "", - uid = "", - expiry = "", - message = "Invalid credentials", - ), - ) - } - } - - /** - * Logout endpoint that revokes the current authentication token. - * - * This endpoint: - * 1. Extracts the current token from request headers/cookies - * 2. Revokes the token and all associated sub-tokens - * 3. Clears authentication cookies - * - * Example request: - * ```bash - * curl -X POST http://localhost:8080/api/auth/logout \ - * -H "access-token: " \ - * -H "client: " \ - * -H "uid: " \ - * -H "expiry: " - * ``` - * - * @param authentication Current authentication context - * @param httpRequest HTTP request for extracting token - * @param httpResponse HTTP response for clearing cookies - * @return Logout confirmation message - */ - @PostMapping("/logout") - fun logout( - authentication: Authentication?, - httpRequest: HttpServletRequest, - httpResponse: HttpServletResponse, - ): ResponseEntity> { - if (authentication == null || !authentication.isAuthenticated) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(mapOf("message" to "Not authenticated")) - } - - return try { - val user = userDirectory.findByUsername(authentication.name) - if (user != null) { - tokenService.revokeClient(user.getOgiriUserId(), httpRequest, httpResponse) - } - ResponseEntity.ok(mapOf("message" to "Logout successful")) - } catch (e: Exception) { - ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(mapOf("message" to "Logout failed: ${e.message}")) - } - } -} - -/** Login request body. */ -data class LoginRequest( - val username: String, - val password: String, -) - -/** Authentication response body. */ -data class AuthResponse( - val accessToken: String, - val client: String, - val uid: String, - val expiry: String, - val message: String, -) diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/DemoController.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/DemoController.kt deleted file mode 100644 index ef77c45..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/DemoController.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.controller - -import com.quantipixels.ogiri.samples.kotlin.util.authBase -import com.quantipixels.ogiri.samples.kotlin.util.detectAuthMethod -import jakarta.servlet.http.HttpServletRequest -import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -/** - * Demo controller showcasing different authentication methods. - * - * This controller demonstrates that Ogiri Security supports three authentication methods: - * 1. HTTP Headers (access-token, client, uid, expiry) - * 2. Secure Cookies (same fields as cookies) - * 3. Bearer Token (Authorization: Bearer ) - * - * All three methods are functionally equivalent and work with the same authentication filter. - */ -@RestController -@RequestMapping("/api/demo") -class DemoController { - - /** Shows authentication via HTTP headers (access-token, client, uid, expiry). */ - @GetMapping("/headers") - fun demonstrateHeaderAuth( - authentication: Authentication?, - request: HttpServletRequest, - ): ResponseEntity> = - ResponseEntity.ok( - authBase("HTTP Headers", authentication) + - mapOf( - "receivedHeaders" to - mapOf( - "access-token" to request.getHeader("access-token"), - "client" to request.getHeader("client"), - "uid" to request.getHeader("uid"), - "expiry" to request.getHeader("expiry"), - ), - ), - ) - - /** Shows authentication via secure cookies (same four fields set as HttpOnly cookies). */ - @GetMapping("/cookies") - fun demonstrateCookieAuth( - authentication: Authentication?, - request: HttpServletRequest, - ): ResponseEntity> { - val cookies = request.cookies?.associate { it.name to it.value } ?: emptyMap() - return ResponseEntity.ok( - authBase("Secure Cookies", authentication) + - mapOf( - "receivedCookies" to - mapOf( - "access-token" to cookies["access-token"], - "client" to cookies["client"], - "uid" to cookies["uid"], - "expiry" to cookies["expiry"], - ), - ), - ) - } - - /** Shows authentication via Authorization: Bearer (Base64-encoded JSON). */ - @GetMapping("/bearer") - fun demonstrateBearerAuth( - authentication: Authentication?, - request: HttpServletRequest, - ): ResponseEntity> = - ResponseEntity.ok( - authBase("Bearer Token", authentication) + - mapOf( - "authorizationHeader" to (request.getHeader("Authorization") ?: "Not provided"), - "note" to - "Bearer token is Base64-encoded JSON with access-token, client, uid, expiry", - ), - ) - - /** General info endpoint that works with any authentication method. */ - @GetMapping("/info") - fun getAuthInfo( - authentication: Authentication?, - request: HttpServletRequest, - ): ResponseEntity> = - ResponseEntity.ok( - authBase(authentication) + - mapOf( - "authorities" to - (authentication?.authorities?.map { it.authority } ?: emptyList()), - "authMethod" to detectAuthMethod(request), - "message" to - "This endpoint accepts authentication via headers, cookies, or Bearer token", - ), - ) -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthController.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthController.kt deleted file mode 100644 index b5f2ee9..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthController.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.controller - -import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("/api") -class HealthController { - @GetMapping("/health") - fun health(): ResponseEntity> { - return ResponseEntity.ok(mapOf("status" to "UP")) - } - - @GetMapping("/me") - fun me(authentication: Authentication?): ResponseEntity> { - return ResponseEntity.ok( - mapOf( - "authenticated" to (authentication != null && authentication.isAuthenticated), - "principal" to (authentication?.name ?: "anonymous"), - ), - ) - } -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/TestController.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/TestController.kt deleted file mode 100644 index 120e947..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/TestController.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.controller - -import com.quantipixels.ogiri.samples.kotlin.repository.SampleTokenRepository -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import jakarta.servlet.http.HttpServletRequest -import java.time.Instant -import org.springframework.context.annotation.Profile -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -/** - * Test-only endpoints for exercising expiration flows in the sample app. - * - * Not for production use. Exposes helpers that manipulate token state directly so the frontend can - * drive the full expiry → 401 → redirect cycle without waiting for a real TTL to elapse. - */ -@RestController -@RequestMapping("/api/test") -@Profile("!jdbc") -class TestController( - private val tokenRepository: SampleTokenRepository, - private val userDirectory: OgiriUserDirectory, -) { - - /** - * Backdates the current session's - * [expiryAt][com.quantipixels.ogiri.jpa.OgiriBaseTokenEntity.expiryAt] to one hour in the past so - * the next authenticated request returns 401. - * - * Requires a valid session (Ogiri filter must authenticate the request before this method runs). - * The `client` header identifies the session to expire. - */ - @PostMapping("/expire-token") - fun expireToken( - authentication: Authentication?, - request: HttpServletRequest, - ): ResponseEntity> { - if (authentication == null || !authentication.isAuthenticated) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(mapOf("message" to "Not authenticated")) - } - - val user = - userDirectory.findByUsername(authentication.name) - ?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(mapOf("message" to "User not found")) - - val client = - request.getHeader("client")?.takeIf { it.isNotBlank() } - ?: return ResponseEntity.badRequest().body(mapOf("message" to "Missing client header")) - - val entity = - tokenRepository.findByUserIdAndClient(user.getOgiriUserId(), client).orElse(null) - ?: return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(mapOf("message" to "Session not found")) - - entity.expiryAt = Instant.now().minusSeconds(3600) - tokenRepository.save(entity) - - return ResponseEntity.ok(mapOf("message" to "Token expired")) - } -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/DirectToken.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/DirectToken.kt deleted file mode 100644 index 4c36b35..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/DirectToken.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.entity - -import com.quantipixels.ogiri.security.tokens.OgiriToken -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.Index -import jakarta.persistence.Table -import jakarta.persistence.Transient -import jakarta.persistence.UniqueConstraint -import java.time.Instant -import org.hibernate.annotations.CreationTimestamp -import org.hibernate.annotations.UpdateTimestamp - -/** - * Example token implementation using direct interface implementation. - * - * This demonstrates how to implement OgiriToken directly without extending OgiriBaseToken. This - * approach provides maximum flexibility - you can: - * - Implement your own base class and have both extend OgiriToken - * - Add custom fields and methods - * - Control inheritance hierarchy completely - * - * Compare this to [SampleToken] which extends OgiriBaseToken for convenience. - */ -@Entity -@Table( - name = "direct_tokens", - indexes = - [ - Index(name = "idx_direct_tokens_user_id", columnList = "user_id"), - Index(name = "idx_direct_tokens_expiry", columnList = "expiry_at"), - ], - uniqueConstraints = - [ - UniqueConstraint( - name = "uk_direct_tokens_user_client", - columnNames = ["user_id", "client"], - ), - ], -) -data class DirectToken( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) override val id: Long = 0, - @Column(name = "user_id", nullable = false) override val userId: Long = 0, - @Column(name = "client", nullable = false) override val client: String = "", - @Column(name = "token_hash", nullable = false) override var token: String = "", - @Column(name = "token_type", nullable = false) override val tokenType: String = "app", - @Column(name = "expiry_at", nullable = false) override var expiryAt: Instant = Instant.now(), - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - override val createdAt: Instant = Instant.now(), - @UpdateTimestamp - @Column(name = "updated_at", nullable = false) - override val updatedAt: Instant = Instant.now(), - @Column(name = "token_updated_at", nullable = false) - override var tokenUpdatedAt: Instant = Instant.now(), - @Column(name = "token_subtype") override var tokenSubtype: String? = null, - @Column(name = "last_token_hash") override var lastToken: String? = null, - @Column(name = "previous_token_hash") override var previousToken: String? = null, - @Column(name = "last_used_at") override var lastUsedAt: Instant? = null, -) : OgiriToken { - @Transient override var plainToken: String? = null -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/SampleToken.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/SampleToken.kt deleted file mode 100644 index 97fb169..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/entity/SampleToken.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.entity - -import com.quantipixels.ogiri.jpa.OgiriBaseTokenEntity -import jakarta.persistence.Entity -import jakarta.persistence.Index -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint - -/** - * Sample JPA Token entity extending OgiriBaseTokenEntity. - * - * All 15+ token fields with proper JPA annotations are inherited from OgiriBaseTokenEntity. This - * class only needs to add @Entity, @Table annotations, and any custom indexes/constraints. - */ -@Entity -@Table( - name = "user_tokens", - indexes = - [ - Index(name = "idx_user_tokens_user_id", columnList = "user_id"), - Index(name = "idx_user_tokens_expiry", columnList = "expiry_at"), - ], - uniqueConstraints = - [ - UniqueConstraint( - name = "uk_user_tokens_user_client", - columnNames = ["user_id", "client"], - ), - ], -) -class SampleToken : OgiriBaseTokenEntity() diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleToken.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleToken.kt deleted file mode 100644 index 16961dd..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleToken.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.jdbc - -import com.quantipixels.ogiri.jdbc.OgiriBaseTokenRow - -/** - * Sample JDBC token extending OgiriBaseTokenRow. - * - * No persistence annotations required — OgiriBaseTokenRow provides all token fields as plain - * properties. Compare to [com.quantipixels.ogiri.samples.kotlin.entity.SampleToken] which extends - * OgiriBaseTokenEntity (JPA). - * - * You can also use OgiriBaseTokenRow directly without subclassing. - */ -class JdbcSampleToken : OgiriBaseTokenRow() diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepository.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepository.kt deleted file mode 100644 index e23b73b..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepository.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.jdbc - -import com.quantipixels.ogiri.jdbc.OgiriJdbcTokenRepository -import org.springframework.context.annotation.Profile -import org.springframework.jdbc.core.RowMapper -import org.springframework.jdbc.core.simple.JdbcClient -import org.springframework.stereotype.Repository - -/** - * JDBC repository for JdbcSampleToken. - * - * Extend [OgiriJdbcTokenRepository] and provide: - * - [tableName]: the target table (must match your schema) - * - [rowMapper]: maps a ResultSet row to your token class - * - * All 15 OgiriTokenRepository methods are handled by the base class via JdbcClient. - */ -@Repository -@Profile("jdbc") -class JdbcSampleTokenRepository(jdbcClient: JdbcClient) : - OgiriJdbcTokenRepository(jdbcClient) { - - override fun tableName() = "user_tokens" - - override fun rowMapper() = RowMapper { rs, _ -> - JdbcSampleToken().apply { - id = rs.getLong("id") - userId = rs.getLong("user_id") - client = rs.getString("client") - token = rs.getString("token_hash") - tokenType = rs.getString("token_type") - tokenSubtype = rs.getString("token_subtype") - expiryAt = rs.getTimestamp("expiry_at").toInstant() - previousToken = rs.getString("previous_token_hash") - lastToken = rs.getString("last_token_hash") - tokenUpdatedAt = rs.getTimestamp("token_updated_at").toInstant() - lastUsedAt = rs.getTimestamp("last_used_at")?.toInstant() - createdAt = rs.getTimestamp("created_at").toInstant() - updatedAt = rs.getTimestamp("updated_at").toInstant() - } - } -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenService.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenService.kt deleted file mode 100644 index 8707085..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenService.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.jdbc - -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import com.quantipixels.ogiri.security.tokens.OgiriTokenType -import java.time.Instant -import org.springframework.context.annotation.Profile -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service - -/** - * Token service for the JDBC-backed sample. - * - * Active only when the "jdbc" Spring profile is enabled. The companion JPA-backed - * [SampleTokenService] is excluded via @Profile("!jdbc"), so only one OgiriTokenService bean exists - * at runtime. - * - * Optional extension points ([com.quantipixels.ogiri.security.spi.OgiriAuditHook], - * [com.quantipixels.ogiri.security.spi.OgiriRateLimitHook], - * [com.quantipixels.ogiri.security.spi.OgiriTokenLookupCache]) are wired automatically by the ogiri - * auto-configuration via setter injection when the corresponding beans are present. - * - * Run with: --spring.profiles.active=jdbc - */ -@Service -@Profile("jdbc") -class JdbcSampleTokenService( - tokenRepository: JdbcSampleTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, -) : - OgiriTokenService( - tokenRepository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties, - ) { - - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): JdbcSampleToken = - JdbcSampleToken().apply { - this.userId = userId - this.client = client - this.token = hashedToken - this.tokenType = tokenType.label - this.expiryAt = expiry - this.tokenSubtype = tokenSubtype - this.plainToken = plainTokenValue - } -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepository.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepository.kt deleted file mode 100644 index 7731c9e..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepository.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.repository - -import com.quantipixels.ogiri.samples.kotlin.entity.SampleToken -import com.quantipixels.ogiri.security.tokens.OgiriTokenRepository -import java.time.Instant -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -import org.springframework.stereotype.Repository -import org.springframework.transaction.annotation.Transactional - -/** - * Repository for SampleToken using the simplified pattern. - * - * Since 1.3.1, OgiriTokenRepository method names follow Spring Data conventions, so Spring Data - * automatically generates all query implementations. No adapter class needed! - * - * For methods that Spring Data cannot auto-generate (bulk deletes), we provide explicit @Query - * annotations. - */ -@Repository -interface SampleTokenRepository : - JpaRepository, OgiriTokenRepository { - - // Spring Data auto-generates these based on method naming conventions: - // - findByUserIdOrderByUpdatedAtDesc(userId) - // - findByUserIdAndClient(userId, client) -> Optional - // - findByUserIdAndClientIn(userId, clients) -> List (for batch sub-token loading) - // - findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(userId, tokenSubtype) - // - findByExpiryAtBefore(cutoff) - // - findByTokenType(tokenType) - - // Override countByUserId with explicit query for performance (avoids loading all tokens) - @Query("SELECT COUNT(t) FROM SampleToken t WHERE t.userId = :userId") - override fun countByUserId(userId: Long): Long - - // Bulk delete operations need explicit @Query (Spring Data naming convention doesn't support - // certain operations) - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.userId = ?1 AND t.client = ?2") - override fun deleteByUserIdAndClient(userId: Long, client: String) - - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.userId = ?1 AND t.client IN ?2") - override fun deleteByUserIdAndClientIn(userId: Long, clients: Collection) - - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.userId = ?1") - override fun deleteByUserId(userId: Long) - - @Transactional - @Modifying - @Query("DELETE FROM SampleToken t WHERE t.expiryAt < ?1") - override fun deleteByExpiryAtBefore(cutoff: Instant): Int -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleOgiriUserDirectory.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleOgiriUserDirectory.kt deleted file mode 100644 index 660b51b..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleOgiriUserDirectory.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.security - -import com.quantipixels.ogiri.security.spi.OgiriUser -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Component - -/** - * Sample OgiriUserDirectory implementation for Kotlin. - * - * In a real application, this would load users from a database. This sample uses an in-memory map - * for demonstration. - */ -@Component -class SampleOgiriUserDirectory(private val passwordEncoder: PasswordEncoder) : OgiriUserDirectory { - private val usersByUsername = mutableMapOf() - private val usersById = mutableMapOf() - - init { - val encodedPassword = passwordEncoder.encode("password") - val user1 = SampleUser(1L, "user1", encodedPassword, "user1@example.com") - val user2 = SampleUser(2L, "user2", encodedPassword, "user2@example.com") - usersByUsername["user1"] = user1 - usersByUsername["user2"] = user2 - usersById[1L] = user1 - usersById[2L] = user2 - } - - override fun loadUserByUsername(username: String): OgiriUser = - usersByUsername[username] ?: throw IllegalArgumentException("User not found: $username") - - override fun findById(id: Long): OgiriUser? = usersById[id] - - override fun findByEmail(email: String): OgiriUser? = - usersById.values.firstOrNull { it.email == email } - - override fun findByUsername(username: String): OgiriUser? = usersByUsername[username] - - override fun recordSuccessfulLogin(userId: Long) { - // In a real application, update user last_login_at timestamp - } - - /** Sample user implementation for Kotlin */ - private data class SampleUser( - private val id: Long, - private val username: String, - private val password: String, - val email: String, - ) : OgiriUser { - override fun getAuthorities(): Collection = - listOf(SimpleGrantedAuthority("ROLE_USER")) - - override fun getPassword(): String = password - - override fun getUsername(): String = username - - override fun isAccountNonExpired(): Boolean = true - - override fun isAccountNonLocked(): Boolean = true - - override fun isCredentialsNonExpired(): Boolean = true - - override fun isEnabled(): Boolean = true - override fun getOgiriUserId(): Long = id - } -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleRouteRegistry.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleRouteRegistry.kt deleted file mode 100644 index f29786e..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SampleRouteRegistry.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.security - -import com.quantipixels.ogiri.security.routes.OgiriRoute -import com.quantipixels.ogiri.security.routes.OgiriRouteRegistry -import org.springframework.http.HttpMethod -import org.springframework.stereotype.Component - -/** - * Sample RouteRegistry implementation for Kotlin. - * - * Declares public/unauthenticated routes that the filter should allow. - */ -@Component -class SampleRouteRegistry : OgiriRouteRegistry { - override fun routes() = - listOf( - OgiriRoute(HttpMethod.POST, "/api/auth/login", rateLimit = true, useAuth = false), - OgiriRoute(HttpMethod.GET, "/api/health", rateLimit = true, useAuth = false), - OgiriRoute(HttpMethod.GET, "/api/docs/**", rateLimit = true, useAuth = false), - ) -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/service/SampleTokenService.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/service/SampleTokenService.kt deleted file mode 100644 index f7aded8..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/service/SampleTokenService.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.service - -import com.quantipixels.ogiri.samples.kotlin.entity.SampleToken -import com.quantipixels.ogiri.samples.kotlin.repository.SampleTokenRepository -import com.quantipixels.ogiri.security.config.OgiriConfigurationProperties -import com.quantipixels.ogiri.security.core.IdentifierPolicy -import com.quantipixels.ogiri.security.spi.OgiriUserDirectory -import com.quantipixels.ogiri.security.tokens.OgiriSubTokenRegistry -import com.quantipixels.ogiri.security.tokens.OgiriTokenService -import com.quantipixels.ogiri.security.tokens.OgiriTokenType -import java.time.Instant -import org.springframework.context.annotation.Profile -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service - -/** - * Sample TokenService for the Kotlin example app. - * - * Extends OgiriTokenService and overrides [tokenFactory] to instantiate [SampleToken]. Optional - * extension points (audit hook, rate-limit hook, lookup cache) are wired automatically by the ogiri - * auto-configuration via setter injection. - */ -@Service -@Profile("!jdbc") -class SampleTokenService( - tokenRepository: SampleTokenRepository, - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, -) : - OgiriTokenService( - tokenRepository, - passwordEncoder, - userDirectory, - identifierPolicy, - subTokenRegistry, - properties, - ) { - - override fun tokenFactory( - userId: Long, - client: String, - hashedToken: String, - tokenType: OgiriTokenType, - expiry: Instant, - tokenSubtype: String?, - plainTokenValue: String, - ): SampleToken = - SampleToken().apply { - this.userId = userId - this.client = client - this.token = hashedToken - this.tokenType = tokenType.name - this.expiryAt = expiry - this.tokenSubtype = tokenSubtype - this.plainToken = plainTokenValue - } -} diff --git a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/util/SampleAuthUtils.kt b/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/util/SampleAuthUtils.kt deleted file mode 100644 index a71300a..0000000 --- a/sample/sample-kotlin/src/main/kotlin/com/quantipixels/ogiri/samples/kotlin/util/SampleAuthUtils.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.util - -import jakarta.servlet.http.HttpServletRequest -import org.springframework.security.core.Authentication - -/** Detects which Ogiri authentication method was used in the request. */ -fun detectAuthMethod(request: HttpServletRequest): String = - when { - request.getHeader("Authorization")?.startsWith("Bearer ") == true -> "Bearer Token" - request.cookies?.any { it.name == "access-token" } == true -> "Cookie" - request.getHeader("access-token") != null -> "Header" - else -> "None" - } - -/** Builds a base response map with the caller's authentication state. */ -fun authBase(authentication: Authentication?): Map = - mapOf( - "authenticated" to (authentication != null && authentication.isAuthenticated), - "principal" to (authentication?.name ?: "anonymous"), - ) - -/** Builds a base response map including the named authentication method. */ -fun authBase(method: String, authentication: Authentication?): Map = - mapOf("method" to method) + authBase(authentication) diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/FullCycleHttpTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/FullCycleHttpTest.kt deleted file mode 100644 index 50aa7cd..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/FullCycleHttpTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin - -import com.quantipixels.ogiri.samples.kotlin.repository.SampleTokenRepository -import java.time.Instant -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT -import org.springframework.boot.test.web.client.TestRestTemplate -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.test.context.ActiveProfiles - -/** - * Full HTTP cycle test — Kotlin sample app. - * - * Starts a real server on a random port and drives the complete auth cycle via TestRestTemplate: - * login → rotation → stale-token rejection → logout → post-logout rejection. - * - * Configuration overrides: - * - register-filter=true re-enables the Ogiri security filter (disabled in the base test profile) - * - rotate-stale-seconds=0 forces rotation on every request (skips the shouldRotate() guard) - * - batch-grace-seconds=0 disables the batch window so rotation is never suppressed - * - cookies.enabled=false tests header-only auth; avoids cookie complexity over plain HTTP - * - * Note: @Transactional does not roll back across RANDOM_PORT tests because the HTTP server runs in - * a separate thread. Cleanup is performed in @BeforeEach instead. - */ -@SpringBootTest( - webEnvironment = RANDOM_PORT, - properties = - [ - "ogiri.security.register-filter=true", - "ogiri.auth.rotate-stale-seconds=0", - "ogiri.auth.batch-grace-seconds=0", - "ogiri.cookies.enabled=false", - ], -) -@ActiveProfiles("test") -class FullCycleHttpTest { - - @Autowired lateinit var rest: TestRestTemplate - @Autowired lateinit var tokenRepository: SampleTokenRepository - - @BeforeEach fun clean() = tokenRepository.deleteAll() - - @Test - fun `full auth cycle - login, rotation, stale rejection, logout`() { - // ── 1. Login ────────────────────────────────────────────────────────────────────── - val loginBody = mapOf("username" to "user1@example.com", "password" to "password") - val loginResponse = rest.postForEntity("/api/auth/login", loginBody, Map::class.java) - assertEquals(200, loginResponse.statusCode.value(), "Login should succeed") - - val token0 = - requireNotNull(loginResponse.headers.getFirst("access-token")) { - "Login must return access-token header" - } - val client = - requireNotNull(loginResponse.headers.getFirst("client")) { - "Login must return client header" - } - val uid = - requireNotNull(loginResponse.headers.getFirst("uid")) { "Login must return uid header" } - val expiry = - requireNotNull(loginResponse.headers.getFirst("expiry")) { - "Login must return expiry header" - } - - // ── 2. First authenticated request — filter rotates the token ───────────────── - val r1 = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - HttpEntity(authHeaders(token0, client, uid, expiry)), - Map::class.java, - ) - assertEquals(200, r1.statusCode.value(), "First authenticated request should succeed") - - val token1 = - requireNotNull(r1.headers.getFirst("access-token")) { - "Filter must return rotated token in response headers" - } - assertNotEquals(token0, token1, "Rotated token must differ from the login token") - - // ── 3. Original token is stale — must be rejected ───────────────────────────── - val stale = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - HttpEntity(authHeaders(token0, client, uid, expiry)), - Map::class.java, - ) - assertEquals(401, stale.statusCode.value(), "Stale token must be rejected after rotation") - - // ── 4. Rotated token is accepted and produces another rotation ──────────────── - val r2 = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - HttpEntity(authHeaders(token1, client, uid, expiry)), - Map::class.java, - ) - assertEquals(200, r2.statusCode.value(), "Rotated token should be accepted") - val token2 = requireNotNull(r2.headers.getFirst("access-token")) - - // ── 5. Logout with the current token ────────────────────────────────────────── - val logout = - rest.exchange( - "/api/auth/logout", - HttpMethod.POST, - HttpEntity(authHeaders(token2, client, uid, expiry)), - Map::class.java, - ) - assertEquals(200, logout.statusCode.value(), "Logout should succeed") - - // ── 6. Session is fully invalidated — any token rejected ────────────────────── - val postLogout = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - HttpEntity(authHeaders(token2, client, uid, expiry)), - Map::class.java, - ) - assertEquals(401, postLogout.statusCode.value(), "Session must be invalidated after logout") - } - - @Test - fun `expired token is rejected`() { - // ── 1. Login ────────────────────────────────────────────────────────────────────── - val loginBody = mapOf("username" to "user1@example.com", "password" to "password") - val loginResponse = rest.postForEntity("/api/auth/login", loginBody, Map::class.java) - assertEquals(200, loginResponse.statusCode.value(), "Login should succeed") - - val token0 = - requireNotNull(loginResponse.headers.getFirst("access-token")) { - "Login must return access-token header" - } - val client = - requireNotNull(loginResponse.headers.getFirst("client")) { - "Login must return client header" - } - val uid = - requireNotNull(loginResponse.headers.getFirst("uid")) { "Login must return uid header" } - val expiry = - requireNotNull(loginResponse.headers.getFirst("expiry")) { - "Login must return expiry header" - } - - // ── 2. Confirm the token is accepted before expiry ──────────────────────────── - val r1 = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - HttpEntity(authHeaders(token0, client, uid, expiry)), - Map::class.java, - ) - assertEquals(200, r1.statusCode.value(), "Request before expiry should succeed") - - // The filter rotates the token on every request (rotate-stale-seconds=0), so use - // the rotated token for subsequent requests. - val currentToken = r1.headers.getFirst("access-token") ?: token0 - - // ── 3. Backdate the token's expiryAt in the DB ──────────────────────────────── - val entity = - tokenRepository.findAll().firstOrNull { it.client == client } - ?: throw AssertionError("Session must exist in the DB") - entity.expiryAt = Instant.now().minusSeconds(3600) - tokenRepository.save(entity) - - // ── 4. Expired token must be rejected with 401 ──────────────────────────────── - val expired = - rest.exchange( - "/api/demo/info", - HttpMethod.GET, - HttpEntity(authHeaders(currentToken, client, uid, expiry)), - Map::class.java, - ) - assertEquals(401, expired.statusCode.value(), "Expired token must be rejected") - } - - // ── helpers ─────────────────────────────────────────────────────────────────────────── - - private fun authHeaders(accessToken: String, client: String, uid: String, expiry: String) = - HttpHeaders().apply { - set("access-token", accessToken) - set("client", client) - set("uid", uid) - set("expiry", expiry) - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/TokenServiceIntegrationTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/TokenServiceIntegrationTest.kt deleted file mode 100644 index f5faf0c..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/TokenServiceIntegrationTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin - -import com.quantipixels.ogiri.samples.kotlin.repository.SampleTokenRepository -import com.quantipixels.ogiri.samples.kotlin.service.SampleTokenService -import com.quantipixels.ogiri.security.core.ACCESS_TOKEN -import com.quantipixels.ogiri.security.core.CLIENT -import com.quantipixels.ogiri.security.core.EXPIRY -import com.quantipixels.ogiri.security.core.SecurityServiceException -import com.quantipixels.ogiri.security.core.UID -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.test.context.ActiveProfiles -import org.springframework.transaction.annotation.Transactional - -@SpringBootTest -@ActiveProfiles("test") -@Transactional -class TokenServiceIntegrationTest { - @Autowired private lateinit var tokenService: SampleTokenService - @Autowired private lateinit var tokenRepository: SampleTokenRepository - - private val testUserId = 1L - private val testEmail = "user1@example.com" - private val testPassword = "password" - - @BeforeEach - fun setUp() { - tokenRepository.deleteAll() - SecurityContextHolder.clearContext() - } - - @Test - fun `createNewAuthToken with null client generates client and persists APP token`() { - val authHeader = tokenService.createNewAuthToken(testUserId, null, null) - - assertNotNull(authHeader.accessToken) - assertNotNull(authHeader.client) - assertEquals("user1", authHeader.uid) - - val savedToken = - tokenRepository.findByUserIdAndClient(testUserId, authHeader.client!!).orElse(null) - assertNotNull(savedToken) - assertEquals(testUserId, savedToken!!.userId) - assertEquals(authHeader.client, savedToken.client) - assertEquals("APP", savedToken.tokenType) - } - - @Test - fun `verifyUser authenticates and appends auth headers`() { - val request = - MockHttpServletRequest("POST", "/api/auth/login").apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - tokenService.verifyUser(request, response, testEmail, testPassword) - - val authentication = SecurityContextHolder.getContext().authentication - assertNotNull(authentication) - assertEquals("user1", authentication.name) - assertNotNull(response.getHeader(ACCESS_TOKEN)) - assertNotNull(response.getHeader(CLIENT)) - assertEquals("user1", response.getHeader(UID)) - assertNotNull(response.getHeader(EXPIRY)) - } - - @Test - fun `verifyUser rejects invalid credentials without creating auth context`() { - val request = - MockHttpServletRequest("POST", "/api/auth/login").apply { remoteAddr = "127.0.0.1" } - val response = MockHttpServletResponse() - - assertThrows(SecurityServiceException::class.java) { - tokenService.verifyUser(request, response, testEmail, "wrong-password") - } - assertNull(SecurityContextHolder.getContext().authentication) - assertEquals(0, tokenRepository.findByUserIdOrderByUpdatedAtDesc(testUserId).size) - } - - @Test - fun `createNewAuthToken rotates token for same client while keeping single persisted row`() { - val first = tokenService.createNewAuthToken(testUserId, "web", null) - val second = tokenService.createNewAuthToken(testUserId, "web", null) - - assertNotEquals(first.accessToken, second.accessToken) - val webTokens = - tokenRepository.findByUserIdOrderByUpdatedAtDesc(testUserId).filter { it.client == "web" } - assertEquals(1, webTokens.size) - } - - @Test - fun `deleteToken removes only targeted client token for same user`() { - tokenService.createNewAuthToken(testUserId, "mobile", null) - tokenService.createNewAuthToken(testUserId, "web", null) - - tokenService.deleteToken(testUserId, "mobile") - - assertNull(tokenRepository.findByUserIdAndClient(testUserId, "mobile").orElse(null)) - assertNotNull(tokenRepository.findByUserIdAndClient(testUserId, "web").orElse(null)) - } - - @Test - fun `revokeClient removes token for client represented by headers`() { - val issued = tokenService.createNewAuthToken(testUserId, "mobile", null) - assertNotNull(tokenRepository.findByUserIdAndClient(testUserId, "mobile").orElse(null)) - val accessToken = requireNotNull(issued.accessToken) - val client = requireNotNull(issued.client) - val uid = requireNotNull(issued.uid) - val expiry = requireNotNull(issued.expiry) - - val request = - MockHttpServletRequest("POST", "/api/auth/logout").apply { - addHeader(ACCESS_TOKEN, accessToken) - addHeader(CLIENT, client) - addHeader(UID, uid) - addHeader(EXPIRY, expiry) - } - val response = MockHttpServletResponse() - - tokenService.revokeClient(testUserId, request, response) - - assertNull(tokenRepository.findByUserIdAndClient(testUserId, "mobile").orElse(null)) - assertEquals(accessToken, response.getHeader(ACCESS_TOKEN)) - assertEquals(client, response.getHeader(CLIENT)) - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/V4QuickstartTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/V4QuickstartTest.kt deleted file mode 100644 index 44874f6..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/V4QuickstartTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin - -import com.quantipixels.ogiri.session.HmacSha256TokenHasher -import com.quantipixels.ogiri.session.OgiriSessions -import com.quantipixels.ogiri.session.OpaqueTokenCodec -import com.quantipixels.ogiri.session.SessionManager -import com.quantipixels.ogiri.session.SubjectStatusChecker -import com.quantipixels.ogiri.test.InMemorySessionStore -import java.time.Clock -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class V4QuickstartTest { - @Test - fun `documented v4 core flow round-trips`() { - val codec = OpaqueTokenCodec() - val sessions = - SessionManager( - InMemorySessionStore(), - codec, - HmacSha256TokenHasher("primary", mapOf("primary" to ByteArray(32) { 7 })), - SubjectStatusChecker { true }, - Clock.systemUTC(), - ) - val issued = - sessions.issue( - OgiriSessions.subject("users", "opaque-user-id", "tenant-a"), - OgiriSessions.client("browser-id", "Work laptop"), - ) - - val authenticated = sessions.authenticate(issued.credential.encoded(codec)) - - assertEquals("opaque-user-id", authenticated.subject.subjectId.value) - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthControllerTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthControllerTest.kt deleted file mode 100644 index 111816a..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/controller/HealthControllerTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.controller - -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.http.MediaType -import org.springframework.test.context.ActiveProfiles -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get - -@SpringBootTest -@AutoConfigureMockMvc(addFilters = false) -@ActiveProfiles("test") -class HealthControllerTest { - - @Autowired private lateinit var mockMvc: MockMvc - - @Test - fun `health endpoint returns UP status`() { - mockMvc - .get("/api/health") { accept = MediaType.APPLICATION_JSON } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.status") { value("UP") } - } - } - - @Test - fun `me endpoint without authentication returns anonymous`() { - mockMvc - .get("/api/me") { accept = MediaType.APPLICATION_JSON } - .andExpect { - status { isOk() } - jsonPath("$.authenticated") { value(false) } - jsonPath("$.principal") { value("anonymous") } - } - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepositoryTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepositoryTest.kt deleted file mode 100644 index 4f399d1..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/jdbc/JdbcSampleTokenRepositoryTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.jdbc - -import com.quantipixels.ogiri.samples.kotlin.Application -import java.time.Instant -import java.time.temporal.ChronoUnit -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles - -@SpringBootTest(classes = [Application::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) -@ActiveProfiles("jdbc") -class JdbcSampleTokenRepositoryTest { - - @Autowired private lateinit var tokenRepository: JdbcSampleTokenRepository - - private val testUserId = 100L - private val testClient = "test-client" - private val testToken = "hashed-token-123" - - @BeforeEach - fun setUp() { - tokenRepository.deleteByUserId(testUserId) - tokenRepository.deleteByUserId(101L) - tokenRepository.deleteByUserId(102L) - } - - @Test - fun `should save and retrieve token by user and client`() { - val token = createToken(testUserId, testClient, testToken) - tokenRepository.save(token) - - val retrieved = tokenRepository.findByUserIdAndClient(testUserId, testClient) - - assertTrue(retrieved.isPresent) - assertEquals(testUserId, retrieved.get().userId) - assertEquals(testClient, retrieved.get().client) - assertEquals(testToken, retrieved.get().token) - } - - @Test - fun `should return empty Optional for non-existent token`() { - val retrieved = tokenRepository.findByUserIdAndClient(999L, "non-existent") - assertFalse(retrieved.isPresent) - } - - @Test - fun `should find all tokens for user and exclude tokens from other users`() { - val token1 = createToken(testUserId, "client-1", "token-1") - val token2 = createToken(testUserId, "client-2", "token-2") - val otherUserToken = createToken(101L, "client-3", "token-3") - - tokenRepository.save(token1) - tokenRepository.save(token2) - tokenRepository.save(otherUserToken) - - val tokens = tokenRepository.findByUserIdOrderByUpdatedAtDesc(testUserId) - - assertEquals(2, tokens.size) - val clients = tokens.map { it.client } - assertTrue(clients.contains("client-1")) - assertTrue(clients.contains("client-2")) - } - - @Test - fun `should find expired tokens`() { - val now = Instant.now() - - val expiredToken = - createToken(testUserId, "expired-client", testToken).apply { - expiryAt = now.minus(1, ChronoUnit.HOURS) - } - val validToken = - createToken(testUserId, "valid-client", testToken).apply { - expiryAt = now.plus(1, ChronoUnit.HOURS) - } - - tokenRepository.save(expiredToken) - tokenRepository.save(validToken) - - val expired = tokenRepository.findByExpiryAtBefore(now) - - assertTrue(expired.any { it.client == "expired-client" }) - assertTrue(expired.none { it.expiryAt.isAfter(now) }) - } - - @Test - fun `should delete token by user and client`() { - tokenRepository.save(createToken(testUserId, testClient, testToken)) - - tokenRepository.deleteByUserIdAndClient(testUserId, testClient) - - assertFalse(tokenRepository.findByUserIdAndClient(testUserId, testClient).isPresent) - } - - @Test - fun `should delete selected tokens by client list`() { - tokenRepository.save(createToken(102L, "client-1", "token-1")) - tokenRepository.save(createToken(102L, "client-2", "token-2")) - tokenRepository.save(createToken(102L, "client-3", "token-3")) - - tokenRepository.deleteByUserIdAndClientIn(102L, listOf("client-1", "client-2")) - - val remaining = tokenRepository.findByUserIdOrderByUpdatedAtDesc(102L) - assertEquals(1, remaining.size) - assertEquals("client-3", remaining[0].client) - } - - private fun createToken(userId: Long, client: String, token: String): JdbcSampleToken = - JdbcSampleToken().apply { - this.userId = userId - this.client = client - this.token = token - this.expiryAt = Instant.now().plus(1, ChronoUnit.HOURS) - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepositoryTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepositoryTest.kt deleted file mode 100644 index de044a0..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/repository/SampleTokenRepositoryTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.repository - -import com.quantipixels.ogiri.samples.kotlin.Application -import com.quantipixels.ogiri.samples.kotlin.entity.SampleToken -import java.time.Instant -import java.time.temporal.ChronoUnit -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles -import org.springframework.transaction.annotation.Transactional - -@SpringBootTest(classes = [Application::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) -@ActiveProfiles("test") -@Transactional -class SampleTokenRepositoryTest { - @Autowired private lateinit var tokenRepository: SampleTokenRepository - - private val testUserId = 1L - private val testClient = "test-client" - private val testToken = "hashed-token-123" - - @BeforeEach - fun setUp() { - tokenRepository.deleteAll() - } - - @Test - fun `should save and retrieve token by user and client`() { - val token = createToken(testUserId, testClient, testToken) - tokenRepository.save(token) - - val retrieved = tokenRepository.findByUserIdAndClient(testUserId, testClient) - - assertTrue(retrieved.isPresent) - assertEquals(testUserId, retrieved.get().userId) - assertEquals(testClient, retrieved.get().client) - assertEquals(testToken, retrieved.get().token) - } - - @Test - fun `should return empty Optional for non-existent token`() { - val retrieved = tokenRepository.findByUserIdAndClient(999L, "non-existent") - assertFalse(retrieved.isPresent) - } - - @Test - fun `should find all tokens for user and exclude tokens from other users`() { - val token1 = createToken(testUserId, "client-1", "token-1") - val token2 = createToken(testUserId, "client-2", "token-2") - val otherUserToken = createToken(2L, "client-3", "token-3") - - tokenRepository.save(token1) - tokenRepository.save(token2) - tokenRepository.save(otherUserToken) - - val tokens = tokenRepository.findByUserIdOrderByUpdatedAtDesc(testUserId) - - assertEquals(2, tokens.size) - val clients = tokens.map { it.client } - assertTrue(clients.contains("client-1")) - assertTrue(clients.contains("client-2")) - } - - @Test - fun `should find expired tokens`() { - val now = Instant.now() - - val expiredToken = - createToken(testUserId, "expired-client", testToken).apply { - expiryAt = now.minus(1, ChronoUnit.HOURS) - } - val validToken = - createToken(testUserId, "valid-client", testToken).apply { - expiryAt = now.plus(1, ChronoUnit.HOURS) - } - - tokenRepository.save(expiredToken) - tokenRepository.save(validToken) - - val expired = tokenRepository.findByExpiryAtBefore(now) - - assertEquals(1, expired.size) - assertEquals("expired-client", expired[0].client) - } - - @Test - fun `should delete token by user and client`() { - val token = createToken(testUserId, testClient, testToken) - tokenRepository.save(token) - - tokenRepository.deleteByUserIdAndClient(testUserId, testClient) - - val retrieved = tokenRepository.findByUserIdAndClient(testUserId, testClient) - assertFalse(retrieved.isPresent) - } - - @Test - fun `should delete selected tokens by client list`() { - val token1 = createToken(testUserId, "client-1", "token-1") - val token2 = createToken(testUserId, "client-2", "token-2") - val token3 = createToken(testUserId, "client-3", "token-3") - - tokenRepository.save(token1) - tokenRepository.save(token2) - tokenRepository.save(token3) - - tokenRepository.deleteByUserIdAndClientIn(testUserId, listOf("client-1", "client-2")) - - val remaining = tokenRepository.findByUserIdOrderByUpdatedAtDesc(testUserId) - assertEquals(1, remaining.size) - assertEquals("client-3", remaining[0].client) - } - - private fun createToken( - userId: Long, - client: String, - token: String, - ): SampleToken = - SampleToken().apply { - this.userId = userId - this.client = client - this.token = token - this.expiryAt = Instant.now().plus(1, ChronoUnit.HOURS) - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SecurityConfigTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SecurityConfigTest.kt deleted file mode 100644 index 3185e88..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/SecurityConfigTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.security - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.HttpMethod - -class SecurityConfigTest { - - private lateinit var routeRegistry: SampleRouteRegistry - - @BeforeEach - fun setUp() { - routeRegistry = SampleRouteRegistry() - } - - @Test - fun `route registry exposes exact public routes with rate limiting enabled`() { - val routes = routeRegistry.routes() - val actual = - routes - .map { route -> - listOf(route.method.name(), route.path, route.useAuth, route.rateLimit) - } - .toSet() - val expected = - setOf( - listOf(HttpMethod.POST.name(), "/api/auth/login", false, true), - listOf(HttpMethod.GET.name(), "/api/health", false, true), - listOf(HttpMethod.GET.name(), "/api/docs/**", false, true), - ) - - assertEquals(expected, actual) - } -} diff --git a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/UserDirectoryTest.kt b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/UserDirectoryTest.kt deleted file mode 100644 index 6045afd..0000000 --- a/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/security/UserDirectoryTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2025 Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ -package com.quantipixels.ogiri.samples.kotlin.security - -import org.junit.jupiter.api.Assertions.assertDoesNotThrow -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder - -class UserDirectoryTest { - - private lateinit var userDirectory: SampleOgiriUserDirectory - private val passwordEncoder: PasswordEncoder = BCryptPasswordEncoder() - - @BeforeEach - fun setUp() { - userDirectory = SampleOgiriUserDirectory(passwordEncoder) - } - - @Test - fun `loadUserByUsername returns user for valid username`() { - val user = userDirectory.loadUserByUsername("user1") - - assertNotNull(user) - assertEquals("user1", user.username) - assertEquals(1L, user.getOgiriUserId()) - } - - @Test - fun `loadUserByUsername throws exception for unknown username`() { - assertThrows(IllegalArgumentException::class.java) { - userDirectory.loadUserByUsername("nonexistent") - } - } - - @Test - fun `findByEmail resolves known user and returns null for unknown`() { - val found = userDirectory.findByEmail("user1@example.com") - val missing = userDirectory.findByEmail("unknown@example.com") - - assertNotNull(found) - assertEquals("user1", found!!.username) - assertNull(missing) - } - - @Test - fun `loaded user has expected authorities and enabled flags`() { - val user = userDirectory.loadUserByUsername("user1") - - assertEquals(1, user.authorities.size) - assertTrue(user.authorities.contains(SimpleGrantedAuthority("ROLE_USER"))) - assertTrue(user.isEnabled) - assertTrue(user.isAccountNonExpired) - assertTrue(user.isAccountNonLocked) - assertTrue(user.isCredentialsNonExpired) - } - - @Test - fun `recordSuccessfulLogin is a no-op for sample directory`() { - assertDoesNotThrow { userDirectory.recordSuccessfulLogin(1L) } - } -} diff --git a/sample/sample-react/README.md b/sample/sample-react/README.md deleted file mode 100644 index ab4b8d0..0000000 --- a/sample/sample-react/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# sample-react - -Demonstrates Ogiri token authentication in a React app with no external auth library. - -Runs standalone against an MSW mock server, or against a real Spring Boot backend (Java or Kotlin) via `run-live.sh`. - -## Tech Stack - -- Vite + React 19 + TypeScript -- Axios + TanStack React Query -- MSW 2 (mock API server) -- `src/lib/auth.ts` — inlined auth primitives (copy into your own project) -- `src/lib/axios-ogiri.ts` — axios interceptor wiring (copy alongside `auth.ts`) - -## Run (mock backend) - -```bash -pnpm install -pnpm dev -``` - -Navigate to http://localhost:5173. Login with `user1@example.com` / `password`. - -A **mock-server** badge appears in the dashboard header confirming the active backend. - -## Run (live backend) - -`run-live.sh` starts the Spring Boot server, waits for it to be ready, then either runs vitest integration tests or opens a proxied Vite dev server. - -```bash -# Vitest integration tests (default: kotlin) -./run-live.sh [java|kotlin] - -# Vite dev server proxied to the real backend -./run-live.sh [java|kotlin] --ui -``` - -In `--ui` mode, MSW is bypassed and `/api/*` is proxied to the Spring Boot server. The dashboard badge shows **kotlin-server** or **java-server**. - -## What It Demonstrates - -- **OgiriAuth + axios interceptors** — the primary consumer pattern (`src/api/client.ts`) -- **Token rotation** — visible on the dashboard when making requests; rotated `access-token` highlighted on change -- **Expiry flow** — "Expire Token" button backdates the session expiry; the next request returns 401 and redirects to login -- **Protected routes** — unauthenticated users redirected to login -- **React context + useSyncExternalStore** — reactive auth state without polling -- **MSW mock server** — replicates the Ogiri auth protocol (login, token rotation, session management) -- **Backend badge** — pill in the dashboard header identifying the active backend (`mock-server` / `kotlin-server` / `java-server`) - -## Default Credentials - -| Field | Value | -| -------- | ------------------- | -| Email | `user1@example.com` | -| Password | `password` | - -A second account (`user2@example.com` / `password`) is also available. - -## Available Commands - -```bash -pnpm dev # Dev server (MSW mock backend) -pnpm build # Production build -pnpm test # Unit tests (db.test.ts, auth.integration.test.ts skipped) -pnpm lint # Lint with oxlint -pnpm format:check # Check formatting with oxfmt -``` - -Live tests are run via `run-live.sh`, not directly via `pnpm test:live`. diff --git a/sample/sample-react/index.html b/sample/sample-react/index.html deleted file mode 100644 index 47a8d72..0000000 --- a/sample/sample-react/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Ogiri Auth Demo - - -

- - - diff --git a/sample/sample-react/oxlintrc.json b/sample/sample-react/oxlintrc.json deleted file mode 100644 index 4ac54cd..0000000 --- a/sample/sample-react/oxlintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxlint-config-schema/main/.schema.json", - "rules": { - "no-console": "warn", - "no-debugger": "error", - "no-unused-vars": "warn", - "eqeqeq": "error", - "no-var": "error", - "prefer-const": "warn" - } -} diff --git a/sample/sample-react/package.json b/sample/sample-react/package.json deleted file mode 100644 index fa41d02..0000000 --- a/sample/sample-react/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "sample-react", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview", - "lint": "oxlint src/", - "format": "oxfmt src/", - "format:check": "oxfmt --check src/", - "test": "vitest run", - "test:live": "vitest run src/lib/auth.integration.test.ts", - "test:watch": "vitest" - }, - "dependencies": { - "@tanstack/react-query": "^5.62.0", - "axios": "^1.13.5", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": "^7.1.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.0", - "msw": "^2.7.0", - "oxfmt": "^0.28.0", - "oxlint": "^1.43.0", - "typescript": "^5.3.0", - "vite": "^6.0.0", - "vitest": "4.0.18" - }, - "msw": { - "workerDirectory": [ - "public" - ] - } -} \ No newline at end of file diff --git a/sample/sample-react/public/mockServiceWorker.js b/sample/sample-react/public/mockServiceWorker.js deleted file mode 100644 index 85e9010..0000000 --- a/sample/sample-react/public/mockServiceWorker.js +++ /dev/null @@ -1,349 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - */ - -const PACKAGE_VERSION = '2.12.9' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -addEventListener('install', function () { - self.skipWaiting() -}) - -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() - - // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been terminated (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) - -/** - * @param {FetchEvent} event - * @param {string} requestId - * @param {number} requestInterceptedAt - */ -async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) - - // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - isMockedResponse: IS_MOCKED_RESPONSE in response, - request: { - id: requestId, - ...serializedRequest, - }, - response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, - }, - }, - }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) - } - - return response -} - -/** - * Resolve the main client for the given event. - * Client that issues a request doesn't necessarily equal the client - * that registered the worker. It's with the latter the worker should - * communicate with during the response resolving phase. - * @param {FetchEvent} event - * @returns {Promise} - */ -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -/** - * @param {FetchEvent} event - * @param {Client | undefined} client - * @param {string} requestId - * @param {number} requestInterceptedAt - * @returns {Promise} - */ -async function getResponse(event, client, requestId, requestInterceptedAt) { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() - - function passthrough() { - // Cast the request headers to a new Headers instance - // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) - - // Remove the "accept" header value that marked this request as passthrough. - // This prevents request alteration and also keeps it compliant with the - // user-defined CORS policies. - const acceptHeader = headers.get('accept') - if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) - - if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) - } else { - headers.delete('accept') - } - } - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - interceptedAt: requestInterceptedAt, - ...serializedRequest, - }, - }, - [serializedRequest.body], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -/** - * @param {Client} client - * @param {any} message - * @param {Array} transferrables - * @returns {Promise} - */ -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) -} - -/** - * @param {Response} response - * @returns {Response} - */ -function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} - -/** - * @param {Request} request - */ -async function serializeRequest(request) { - return { - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.arrayBuffer(), - keepalive: request.keepalive, - } -} diff --git a/sample/sample-react/run-live.sh b/sample/sample-react/run-live.sh deleted file mode 100755 index f5808a4..0000000 --- a/sample/sample-react/run-live.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# Run Ogiri live integration tests (or dev UI) against a real Spring Boot sample server. -# -# Usage: -# ./run-live.sh [java|kotlin] # run vitest integration tests (default) -# ./run-live.sh [java|kotlin] --ui # start Vite dev server proxied to the real backend -# -# Starts the Java (port 48080) or Kotlin (port 48081) sample app with forced -# token rotation, then either runs the React integration tests or opens the -# Vite dev server (MSW bypassed, /api/* proxied to Spring Boot). -# Defaults to kotlin. -set -euo pipefail - -SAMPLE="${1:-kotlin}" -MODE="${2:-test}" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -case "$SAMPLE" in - java) - MODULE=":sample:sample-java" - PORT=48080 - ;; - kotlin) - MODULE=":sample:sample-kotlin" - PORT=48081 - ;; - *) - echo "Usage: $0 [java|kotlin] [--ui]" >&2 - exit 1 - ;; -esac - -if [[ "$MODE" != "--ui" && "$MODE" != "test" ]]; then - echo "Usage: $0 [java|kotlin] [--ui]" >&2 - exit 1 -fi - -SERVER_PID= - -cleanup() { - if [[ -n "${SERVER_PID}" ]]; then - echo "Stopping server (PID $SERVER_PID)..." - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -echo "Starting $SAMPLE server on port $PORT (rotation forced on every request)..." -(cd "$ROOT" && ./gradlew "$MODULE:bootRun" \ - -Dogiri.auth.rotate-stale-seconds=0 \ - -Dogiri.auth.batch-grace-seconds=0 \ - -Dogiri.cookies.enabled=false) & -SERVER_PID=$! - -echo "Waiting for server to be ready at http://localhost:$PORT..." -MAX_WAIT=90 -WAITED=0 -until curl -s --max-time 2 "http://localhost:$PORT/api/demo/info" > /dev/null 2>&1; do - if ! kill -0 "$SERVER_PID" 2>/dev/null; then - echo "Server process exited unexpectedly." >&2 - exit 1 - fi - if [[ $WAITED -ge $MAX_WAIT ]]; then - echo "Server did not respond within ${MAX_WAIT}s." >&2 - exit 1 - fi - sleep 2 - WAITED=$((WAITED + 2)) -done - -echo "Server ready." -cd "$SCRIPT_DIR" - -if [[ "$MODE" == "--ui" ]]; then - echo "Starting Vite dev server (MSW disabled, /api proxied to $SAMPLE on port $PORT)..." - echo "Open http://localhost:5173 in your browser. Press Ctrl+C to stop." - VITE_API_TARGET="http://localhost:$PORT" pnpm dev -else - echo "Running React live integration tests against $SAMPLE (port $PORT)..." - OGIRI_BASE_URL="http://localhost:$PORT" pnpm test:live -fi diff --git a/sample/sample-react/src/App.tsx b/sample/sample-react/src/App.tsx deleted file mode 100644 index fc6aa26..0000000 --- a/sample/sample-react/src/App.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { LoginPage } from "./pages/LoginPage"; -import { DashboardPage } from "./pages/DashboardPage"; -import { ProtectedRoute } from "./auth/ProtectedRoute"; - -export function App() { - return ( - - - } /> - - - - } - /> - } /> - - - ); -} diff --git a/sample/sample-react/src/api/client.ts b/sample/sample-react/src/api/client.ts deleted file mode 100644 index 33d3294..0000000 --- a/sample/sample-react/src/api/client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { OgiriAuth, LocalStorageTokenStorage } from "../lib/auth"; -import { createAxiosInterceptors } from "../lib/axios-ogiri"; -import axios from "axios"; - -export const auth = new OgiriAuth({ - authMethod: "headers", - storage: new LocalStorageTokenStorage(), -}); - -export const api = axios.create({ baseURL: "" }); - -const { request, response } = createAxiosInterceptors(auth); -api.interceptors.request.use(request); -api.interceptors.response.use(response.onFulfilled, response.onRejected); diff --git a/sample/sample-react/src/api/queries.ts b/sample/sample-react/src/api/queries.ts deleted file mode 100644 index bed6958..0000000 --- a/sample/sample-react/src/api/queries.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, auth } from "./client"; - -interface LoginRequest { - username: string; - password: string; -} - -interface LoginResponse { - accessToken: string; - client: string; - uid: string; - expiry: string; - message: string; -} - -interface DemoInfo { - authenticated: boolean; - principal: string; - authorities: string[]; - authMethod: string; - message: string; -} - -export function useLogin() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (credentials: LoginRequest) => { - const response = await api.post("/api/auth/login", credentials); - return response.data; - }, - onSuccess: () => { - // Tokens are extracted from response headers by the axios interceptor. - // No need to call auth.setTokens() here — doing so would fire a - // redundant notify() causing an extra React re-render. - queryClient.invalidateQueries(); - }, - }); -} - -export function useLogout() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - await api.post("/api/auth/logout"); - }, - onSuccess: () => { - auth.clearTokens(); - queryClient.invalidateQueries(); - }, - }); -} - -export function useExpireToken() { - return useMutation({ - mutationFn: async () => { - await api.post("/api/test/expire-token"); - }, - }); -} - -export function useDemoInfo() { - return useQuery({ - queryKey: ["demo-info"], - queryFn: async () => { - const response = await api.get("/api/demo/info"); - return response.data; - }, - enabled: auth.isAuthenticated(), - }); -} diff --git a/sample/sample-react/src/auth/AuthProvider.tsx b/sample/sample-react/src/auth/AuthProvider.tsx deleted file mode 100644 index f0c7c5e..0000000 --- a/sample/sample-react/src/auth/AuthProvider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createContext, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; -import { auth } from "../api/client"; -import type { OgiriTokens } from "../lib/auth"; - -interface AuthContextValue { - isAuthenticated: boolean; - tokens: OgiriTokens | null; - login: (tokens: OgiriTokens) => void; - logout: () => void; -} - -export const AuthContext = createContext(null); - -export function AuthProvider({ children }: { children: ReactNode }) { - const tokens = useSyncExternalStore( - (cb) => auth.subscribe(cb), - () => auth.getTokens(), - ); - - const isAuthenticated = tokens !== null; - - const login = useCallback((t: OgiriTokens) => auth.setTokens(t), []); - const logout = useCallback(() => auth.clearTokens(), []); - - const value = useMemo(() => ({ isAuthenticated, tokens, login, logout }), [isAuthenticated, tokens, login, logout]); - - return {children}; -} diff --git a/sample/sample-react/src/auth/ProtectedRoute.tsx b/sample/sample-react/src/auth/ProtectedRoute.tsx deleted file mode 100644 index 832cba8..0000000 --- a/sample/sample-react/src/auth/ProtectedRoute.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Navigate } from "react-router-dom"; -import { useAuth } from "./useAuth"; -import type { ReactNode } from "react"; - -export function ProtectedRoute({ children }: { children: ReactNode }) { - const { isAuthenticated } = useAuth(); - if (!isAuthenticated) return ; - return children; -} diff --git a/sample/sample-react/src/auth/useAuth.ts b/sample/sample-react/src/auth/useAuth.ts deleted file mode 100644 index 3f8e9a5..0000000 --- a/sample/sample-react/src/auth/useAuth.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from "react"; -import { AuthContext } from "./AuthProvider"; - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used within AuthProvider"); - return ctx; -} diff --git a/sample/sample-react/src/components/BackendBadge.tsx b/sample/sample-react/src/components/BackendBadge.tsx deleted file mode 100644 index d3a7a9f..0000000 --- a/sample/sample-react/src/components/BackendBadge.tsx +++ /dev/null @@ -1,30 +0,0 @@ -const target = import.meta.env.VITE_API_TARGET as string | undefined; - -function getBackend(): { label: string; color: string } { - if (!target) return { label: "mock-server", color: "#6b7280" }; - if (target.includes("48081")) return { label: "kotlin-server", color: "#7c3aed" }; - if (target.includes("48080")) return { label: "java-server", color: "#b45309" }; - return { label: "live-server", color: "#059669" }; -} - -const backend = getBackend(); - -export function BackendBadge() { - return ( - - {backend.label} - - ); -} diff --git a/sample/sample-react/src/components/TokenDisplay.tsx b/sample/sample-react/src/components/TokenDisplay.tsx deleted file mode 100644 index 3a88524..0000000 --- a/sample/sample-react/src/components/TokenDisplay.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { OgiriTokens } from "../lib/auth"; - -interface Props { - tokens: OgiriTokens | null; -} - -export function TokenDisplay({ tokens }: Props) { - const prevTokenRef = useRef(null); - const [highlight, setHighlight] = useState(false); - - useEffect(() => { - const current = tokens?.accessToken ?? null; - if (prevTokenRef.current !== null && prevTokenRef.current !== current) { - setHighlight(true); - const timer = setTimeout(() => setHighlight(false), 1000); - return () => clearTimeout(timer); - } - prevTokenRef.current = current; - }, [tokens?.accessToken]); - - if (!tokens) return

No tokens

; - - return ( -
-
- access-token - {tokens.accessToken.substring(0, 8)}... -
-
- client - {tokens.client} -
-
- expiry - {tokens.expiry} -
-
- ); -} diff --git a/sample/sample-react/src/lib/auth.integration.test.ts b/sample/sample-react/src/lib/auth.integration.test.ts deleted file mode 100644 index beb470a..0000000 --- a/sample/sample-react/src/lib/auth.integration.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * OgiriAuth live integration tests — React sample. - * - * Runs against a real Spring Boot server (Java or Kotlin sample app). - * Skipped unless OGIRI_BASE_URL is set. - * - * Start the server and run these tests via: - * ./run-live.sh [java|kotlin] - * - * Or manually: - * OGIRI_BASE_URL=http://localhost:48081 pnpm test:live - */ -import { beforeEach, describe, expect, it } from "vitest"; -import axios from "axios"; -import { MemoryTokenStorage, OgiriAuth, OgiriAuthError } from "./auth"; -import { createAxiosInterceptors } from "./axios-ogiri"; - -const BASE_URL = process.env.OGIRI_BASE_URL; - -describe.skipIf(!BASE_URL)("OgiriAuth — live integration", () => { - let auth: OgiriAuth; - let api: ReturnType; - - beforeEach(() => { - auth = new OgiriAuth({ authMethod: "headers", storage: new MemoryTokenStorage() }); - api = axios.create({ baseURL: BASE_URL }); - const { request, response } = createAxiosInterceptors(auth); - api.interceptors.request.use(request); - api.interceptors.response.use(response.onFulfilled, response.onRejected); - }); - - it("login extracts all token headers into storage", async () => { - expect(auth.getTokens()).toBeNull(); - - await api.post("/api/auth/login", { username: "user1@example.com", password: "password" }); - - const tokens = auth.getTokens(); - expect(tokens?.accessToken).toBeTruthy(); - expect(tokens?.client).toBeTruthy(); - expect(tokens?.uid).toBeTruthy(); - expect(tokens?.expiry).toBeTruthy(); - expect(tokens?.tokenType).toBe("Bearer"); - }); - - it("invalid credentials return OgiriAuthError", async () => { - await expect( - api.post("/api/auth/login", { username: "user1@example.com", password: "wrong" }), - ).rejects.toBeInstanceOf(OgiriAuthError); - }); - - it("authenticated request injects headers and interceptor extracts rotated token", async () => { - // ── 1. Login ────────────────────────────────────────────────────────── - await api.post("/api/auth/login", { username: "user1@example.com", password: "password" }); - const loginToken = auth.getTokens()!.accessToken; - expect(loginToken).toBeTruthy(); - - // ── 2. Authenticated request ────────────────────────────────────────── - // - Request interceptor injects stored tokens as headers - // - Server receives auth headers → authenticates → returns 200 - // - Server rotates token (rotate-stale-seconds=0 on the test server) - // - Response interceptor extracts rotation headers → updates auth storage - const r = await api.get("/api/demo/info"); - expect(r.data.authenticated).toBe(true); - expect(r.data.authMethod).toBe("Header"); - - const rotatedToken = auth.getTokens()!.accessToken; - expect(rotatedToken).not.toBe(loginToken); - }); -}); diff --git a/sample/sample-react/src/lib/auth.ts b/sample/sample-react/src/lib/auth.ts deleted file mode 100644 index 4615179..0000000 --- a/sample/sample-react/src/lib/auth.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Ogiri auth primitives — copy this file into your project. - * - * No external dependencies. Works with any HTTP client. - * See docs/react-integration.md for usage examples. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface OgiriTokens { - accessToken: string; - client: string; - uid: string; - expiry: string; - tokenType: string; - /** Optional token kind (APP, SUB, etc.) */ - tokenKind?: string; -} - -export type OgiriAuthMethod = "headers" | "bearer" | "cookies"; - -export interface TokenStorage { - get(): OgiriTokens | null; - set(tokens: OgiriTokens): void; - clear(): void; -} - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -export class OgiriAuthError extends Error { - constructor( - message: string, - public readonly status: number, - public readonly body: unknown, - ) { - super(message); - this.name = "OgiriAuthError"; - } -} - -// --------------------------------------------------------------------------- -// Storage implementations -// --------------------------------------------------------------------------- - -/** In-memory storage — safe for SSR, lost on page reload. */ -export class MemoryTokenStorage implements TokenStorage { - private tokens: OgiriTokens | null = null; - get(): OgiriTokens | null { - return this.tokens; - } - set(tokens: OgiriTokens): void { - this.tokens = tokens; - } - clear(): void { - this.tokens = null; - } -} - -/** - * localStorage — persists across page reloads, browser-only. - * - * get() returns the in-memory cache directly (stable reference). - * Re-parsing JSON on every call would produce a new object each time, - * causing useSyncExternalStore to see the snapshot as perpetually changed - * and throw "Maximum update depth exceeded". - */ -export class LocalStorageTokenStorage implements TokenStorage { - private _cache: OgiriTokens | null; - - constructor(private readonly key: string = "ogiri-tokens") { - try { - const stored = localStorage.getItem(key); - this._cache = stored ? JSON.parse(stored) : null; - } catch { - this._cache = null; - } - } - - get(): OgiriTokens | null { - return this._cache; - } - - set(tokens: OgiriTokens): void { - try { - localStorage.setItem(this.key, JSON.stringify(tokens)); - this._cache = tokens; - } catch (err) { - console.error("Failed to save tokens to localStorage:", err instanceof Error ? err.message : String(err)); - } - } - - clear(): void { - try { - localStorage.removeItem(this.key); - this._cache = null; - } catch (err) { - console.error( - "Failed to clear tokens from localStorage:", - err instanceof Error ? err.message : String(err), - ); - } - } -} - -// --------------------------------------------------------------------------- -// Pure functions — inject auth into a request, extract rotated tokens from a response -// --------------------------------------------------------------------------- - -/** - * Inject Ogiri auth tokens into a fetch RequestInit. - * Returns a new config object; does not mutate the original. - */ -export function injectAuth(config: RequestInit, tokens: OgiriTokens, method: OgiriAuthMethod): RequestInit { - const base = normalizeHeaders(config.headers); - - if (method === "headers") { - return { - ...config, - headers: { - ...base, - "access-token": tokens.accessToken, - client: tokens.client, - uid: tokens.uid, - expiry: tokens.expiry, - "token-type": tokens.tokenType, - ...(tokens.tokenKind ? { "access-token-kind": tokens.tokenKind } : {}), - }, - }; - } - - if (method === "bearer") { - const payload: Record = { - "access-token": tokens.accessToken, - client: tokens.client, - uid: tokens.uid, - "token-type": tokens.tokenType, - expiry: tokens.expiry, - ...(tokens.tokenKind ? { "access-token-kind": tokens.tokenKind } : {}), - }; - const base64 = btoa(JSON.stringify(payload)); - return { ...config, headers: { ...base, Authorization: `Bearer ${base64}` } }; - } - - if (method === "cookies") { - if (typeof window !== "undefined") { - console.warn("Cookie auth is not supported in browsers — use 'headers' or 'bearer' instead."); - } - const parts = [ - `access-token=${tokens.accessToken}`, - `client=${tokens.client}`, - `uid=${tokens.uid}`, - `expiry=${tokens.expiry}`, - `token-type=${tokens.tokenType}`, - ...(tokens.tokenKind ? [`access-token-kind=${tokens.tokenKind}`] : []), - ]; - return { ...config, credentials: "include", headers: { ...base, Cookie: parts.join("; ") } }; - } - - const _exhaustive: never = method; - throw new Error(`Unknown auth method: ${String(_exhaustive)}`); -} - -/** - * Extract rotated tokens from a fetch Response's headers. - * Returns null if the response does not carry rotation headers. - */ -export function extractTokens(response: Response): OgiriTokens | null { - const accessToken = response.headers.get("access-token"); - const client = response.headers.get("client"); - const uid = response.headers.get("uid"); - const expiry = response.headers.get("expiry"); - const tokenType = response.headers.get("token-type"); - const tokenKind = response.headers.get("access-token-kind"); - - const present = [accessToken, client, uid, expiry, tokenType].filter(Boolean); - if (present.length > 0 && present.length < 5) { - console.warn(`Partial rotation headers (${present.length}/5). Token rotation skipped.`); - } - - if (!accessToken || !client || !uid || !expiry || !tokenType) return null; - - return { accessToken, client, uid, expiry, tokenType, ...(tokenKind ? { tokenKind } : {}) }; -} - -function normalizeHeaders(headers?: HeadersInit): Record { - if (!headers) return {}; - if (headers instanceof Headers) { - const out: Record = {}; - headers.forEach((v, k) => { - out[k] = v; - }); - return out; - } - if (Array.isArray(headers)) return Object.fromEntries(headers); - return headers as Record; -} - -// --------------------------------------------------------------------------- -// OgiriAuth — central state manager -// --------------------------------------------------------------------------- - -type AuthErrorCallback = (error: OgiriAuthError) => void; -type AuthChangeListener = () => void; - -export interface OgiriAuthConfig { - authMethod?: OgiriAuthMethod; - storage?: TokenStorage; - onAuthError?: AuthErrorCallback; -} - -/** - * Central auth primitive. - * Manages token state and provides adapter factories for fetch, axios, ky, ofetch, etc. - */ -export class OgiriAuth { - private readonly authMethod: OgiriAuthMethod; - private readonly storage: TokenStorage; - private authErrorCallback?: AuthErrorCallback; - private readonly listeners: Set = new Set(); - - constructor(config: OgiriAuthConfig = {}) { - this.authMethod = config.authMethod ?? "headers"; - this.storage = config.storage ?? new MemoryTokenStorage(); - this.authErrorCallback = config.onAuthError; - } - - getTokens(): OgiriTokens | null { - return this.storage.get(); - } - setTokens(tokens: OgiriTokens): void { - this.storage.set(tokens); - this.notify(); - } - clearTokens(): void { - this.storage.clear(); - this.notify(); - } - isAuthenticated(): boolean { - return this.storage.get() !== null; - } - - onAuthError(cb: AuthErrorCallback): void { - this.authErrorCallback = cb; - } - - /** Subscribe to auth state changes. Returns unsubscribe function. */ - subscribe(listener: AuthChangeListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - /** Inject auth tokens into a fetch RequestInit. */ - injectInto(config: RequestInit): RequestInit { - const tokens = this.storage.get(); - return tokens ? injectAuth(config, tokens, this.authMethod) : config; - } - - /** Extract rotated tokens from a fetch Response and store them. */ - extractFrom(response: Response): void { - const rotated = extractTokens(response); - if (rotated) { - this.storage.set(rotated); - this.notify(); - } - } - - /** Handle a 401 — clears tokens and fires the error callback. */ - handleAuthError(body: unknown): OgiriAuthError { - this.storage.clear(); - this.notify(); - const error = new OgiriAuthError("Unauthorized", 401, body); - this.authErrorCallback?.(error); - return error; - } - - /** - * Returns a function that merges auth headers into a plain headers object. - * Use with ky, ofetch, wretch, or any BYO HTTP client. - * - * @example - * const inject = auth.headerInjector() - * const headers = inject({ "Content-Type": "application/json" }) - */ - headerInjector(): (headers: Record) => Record { - return (headers) => { - const injected = this.injectInto({ headers }); - return (injected.headers as Record) ?? headers; - }; - } - - private notify(): void { - for (const l of this.listeners) l(); - } -} diff --git a/sample/sample-react/src/lib/axios-ogiri.ts b/sample/sample-react/src/lib/axios-ogiri.ts deleted file mode 100644 index fea0d2a..0000000 --- a/sample/sample-react/src/lib/axios-ogiri.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Ogiri axios interceptors — copy this alongside auth.ts into your project. - * - * Wires OgiriAuth into an axios instance: injects auth headers on every request, - * extracts rotated tokens from every response, and handles 401s. - * - * Usage: - * const { request, response } = createAxiosInterceptors(auth) - * api.interceptors.request.use(request) - * api.interceptors.response.use(response.onFulfilled, response.onRejected) - */ -import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios"; -import type { OgiriAuth } from "./auth"; - -export interface OgiriAxiosInterceptors { - request: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig; - response: { - onFulfilled: (response: AxiosResponse) => AxiosResponse; - onRejected: (error: AxiosError) => Promise; - }; -} - -/** Ogiri header names carried in every authenticated response. */ -const TOKEN_HEADER_NAMES = ["access-token", "client", "uid", "expiry", "token-type", "access-token-kind"] as const; - -export function createAxiosInterceptors(auth: OgiriAuth): OgiriAxiosInterceptors { - return { - request: (config) => { - const tokens = auth.getTokens(); - if (tokens) { - const injected = auth.injectInto({ headers: {} }); - const headers = injected.headers as Record | undefined; - if (headers) { - for (const [key, value] of Object.entries(headers)) { - config.headers.set(key, value); - } - } - } - return config; - }, - - response: { - onFulfilled: (response) => { - // axios uses a plain object for headers, not the Fetch API Headers class. - // Build a minimal Response-like object so auth.extractFrom() can read them. - const headerMap: Record = {}; - for (const name of TOKEN_HEADER_NAMES) { - const value = response.headers[name]; - if (typeof value === "string") headerMap[name] = value; - } - auth.extractFrom(new Response(null, { headers: new Headers(headerMap) })); - return response; - }, - - onRejected: async (error) => { - if (error.response?.status === 401) { - throw auth.handleAuthError(error.response.data); - } - throw error; - }, - }, - }; -} diff --git a/sample/sample-react/src/main.tsx b/sample/sample-react/src/main.tsx deleted file mode 100644 index ac01649..0000000 --- a/sample/sample-react/src/main.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AuthProvider } from "./auth/AuthProvider"; -import { App } from "./App"; -import "./styles/app.css"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - refetchOnWindowFocus: false, - }, - }, -}); - -async function main() { - // Skip MSW when proxying to a real backend (VITE_API_TARGET set) - if (!import.meta.env.VITE_API_TARGET) { - const { worker } = await import("./mocks/browser"); - await worker.start({ onUnhandledRequest: "bypass" }); - } - - createRoot(document.getElementById("root")!).render( - - - - - - - , - ); -} - -main(); diff --git a/sample/sample-react/src/mocks/browser.ts b/sample/sample-react/src/mocks/browser.ts deleted file mode 100644 index bcd82e4..0000000 --- a/sample/sample-react/src/mocks/browser.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { setupWorker } from "msw/browser"; -import { handlers } from "./handlers"; - -export const worker = setupWorker(...handlers); diff --git a/sample/sample-react/src/mocks/db.test.ts b/sample/sample-react/src/mocks/db.test.ts deleted file mode 100644 index 46c6ef0..0000000 --- a/sample/sample-react/src/mocks/db.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { findUser, findUserByUid, createSession, validateSession, rotateTokens, deleteSession } from "./db"; - -const USER = { username: "user1", password: "password" }; - -describe("createSession", () => { - it("returns all required token fields", () => { - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - expect(tokens["access-token"]).toBeTruthy(); - expect(tokens.client).toBeTruthy(); - expect(tokens.uid).toBe(user.id.toString()); - expect(tokens["token-type"]).toBe("Bearer"); - expect(new Date(tokens.expiry).getTime()).toBeGreaterThan(Date.now()); - }); - - it("expiry is ~1 minute from now", () => { - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - const ttl = new Date(tokens.expiry).getTime() - Date.now(); - - expect(ttl).toBeGreaterThan(55_000); - expect(ttl).toBeLessThan(65_000); - }); - - it("each session gets a unique client ID", () => { - const user = findUser(USER.username, USER.password)!; - const a = createSession(user); - const b = createSession(user); - expect(a.client).not.toBe(b.client); - }); -}); - -describe("validateSession", () => { - it("accepts a valid token", () => { - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - expect(validateSession(tokens.client, tokens["access-token"])).toBeDefined(); - }); - - it("rejects an unknown client", () => { - expect(validateSession("no-such-client", "any-token")).toBeUndefined(); - }); - - it("rejects a wrong access token", () => { - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - expect(validateSession(tokens.client, "wrong-token")).toBeUndefined(); - }); - - it("rejects an expired session", () => { - vi.useFakeTimers(); - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - vi.advanceTimersByTime(61_000); // past the 1-minute expiry - - expect(validateSession(tokens.client, tokens["access-token"])).toBeUndefined(); - vi.useRealTimers(); - }); -}); - -describe("rotateTokens", () => { - afterEach(() => vi.useRealTimers()); - - it("does not rotate immediately after session creation", () => { - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - expect(rotateTokens(tokens.client)).toBeNull(); - }); - - it("rotates after 30 seconds", () => { - vi.useFakeTimers(); - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - vi.advanceTimersByTime(31_000); - const rotated = rotateTokens(tokens.client); - - expect(rotated).not.toBeNull(); - expect(rotated!["access-token"]).not.toBe(tokens["access-token"]); - expect(rotated!.client).toBe(tokens.client); // client ID unchanged - expect(rotated!.uid).toBe(tokens.uid); - }); - - it("does not rotate again before 30 seconds after the last rotation", () => { - vi.useFakeTimers(); - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - vi.advanceTimersByTime(31_000); - const first = rotateTokens(tokens.client); - expect(first).not.toBeNull(); - - vi.advanceTimersByTime(10_000); // only 10 s since last rotation - expect(rotateTokens(tokens.client)).toBeNull(); - }); - - it("rotated token passes validateSession", () => { - vi.useFakeTimers(); - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - vi.advanceTimersByTime(31_000); - const rotated = rotateTokens(tokens.client)!; - - expect(validateSession(rotated.client, rotated["access-token"])).toBeDefined(); - }); - - it("old token is rejected after rotation", () => { - vi.useFakeTimers(); - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - vi.advanceTimersByTime(31_000); - rotateTokens(tokens.client); - - expect(validateSession(tokens.client, tokens["access-token"])).toBeUndefined(); - }); - - it("returns null for an unknown client", () => { - expect(rotateTokens("no-such-client")).toBeNull(); - }); -}); - -describe("deleteSession", () => { - it("invalidates the session", () => { - const user = findUser(USER.username, USER.password)!; - const tokens = createSession(user); - - deleteSession(tokens.client); - - expect(validateSession(tokens.client, tokens["access-token"])).toBeUndefined(); - }); -}); - -describe("findUser / findUserByUid", () => { - it("finds a user by credentials", () => { - expect(findUser("user1", "password")).toBeDefined(); - }); - - it("returns undefined for wrong password", () => { - expect(findUser("user1", "wrong")).toBeUndefined(); - }); - - it("finds a user by uid", () => { - const user = findUser("user1", "password")!; - expect(findUserByUid(user.id.toString())).toEqual(user); - }); -}); diff --git a/sample/sample-react/src/mocks/db.ts b/sample/sample-react/src/mocks/db.ts deleted file mode 100644 index 588b767..0000000 --- a/sample/sample-react/src/mocks/db.ts +++ /dev/null @@ -1,124 +0,0 @@ -export interface MockUser { - id: number; - username: string; - password: string; - email: string; -} - -export interface MockSession { - token: string; - client: string; - uid: string; - expiry: string; - lastRequestAt: number; -} - -// Seed users -const users: MockUser[] = [ - { id: 1, username: "user1", password: "password", email: "user1@example.com" }, - { id: 2, username: "user2", password: "password", email: "user2@example.com" }, -]; - -// Session storage keyed by client ID -const sessions = new Map(); - -export function findUser(usernameOrEmail: string, password: string): MockUser | undefined { - return users.find( - (u) => (u.username === usernameOrEmail || u.email === usernameOrEmail) && u.password === password, - ); -} - -export function findUserByUid(uid: string): MockUser | undefined { - return users.find((u) => u.id.toString() === uid); -} - -function generateToken(): string { - return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); -} - -export function createSession(user: MockUser): { - "access-token": string; - client: string; - uid: string; - expiry: string; - "token-type": string; -} { - const token = generateToken(); - const client = generateToken(); - const uid = user.id.toString(); - const expiry = new Date(Date.now() + 60 * 1000).toISOString(); // 1 minute - const tokenType = "Bearer"; - - sessions.set(client, { - token, - client, - uid, - expiry, - lastRequestAt: Date.now(), - }); - - return { - "access-token": token, - client, - uid, - expiry, - "token-type": tokenType, - }; -} - -export function validateSession(clientId: string, accessToken: string): MockSession | undefined { - const session = sessions.get(clientId); - if (!session || session.token !== accessToken) { - return undefined; - } - if (new Date(session.expiry) < new Date()) { - sessions.delete(clientId); - return undefined; - } - return session; -} - -export function rotateTokens(clientId: string): { - "access-token": string; - client: string; - uid: string; - expiry: string; - "token-type": string; -} | null { - const session = sessions.get(clientId); - if (!session) { - return null; - } - - // Skip rotation if last request was < 30s ago - const now = Date.now(); - if (now - session.lastRequestAt < 30000) { - return null; - } - - // Generate new token, keep same client ID - const newToken = generateToken(); - session.token = newToken; - session.lastRequestAt = now; - sessions.set(clientId, session); - - return { - "access-token": newToken, - client: session.client, - uid: session.uid, - expiry: session.expiry, - "token-type": "Bearer", - }; -} - -export function deleteSession(clientId: string): void { - sessions.delete(clientId); -} - -export function expireSession(clientId: string): boolean { - const session = sessions.get(clientId); - if (!session) return false; - session.expiry = new Date(0).toISOString(); - sessions.set(clientId, session); - return true; -} diff --git a/sample/sample-react/src/mocks/handlers.ts b/sample/sample-react/src/mocks/handlers.ts deleted file mode 100644 index d72e11f..0000000 --- a/sample/sample-react/src/mocks/handlers.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { - findUser, - findUserByUid, - createSession, - validateSession, - rotateTokens, - deleteSession, - expireSession, -} from "./db"; - -export const handlers = [ - // POST /api/auth/login - http.post("/api/auth/login", async ({ request }) => { - const body = (await request.json()) as { username?: string; password?: string }; - - if (!body.username || !body.password) { - return HttpResponse.json({ error: "Missing username or password" }, { status: 400 }); - } - - const user = findUser(body.username, body.password); - if (!user) { - return HttpResponse.json({ error: "Invalid credentials" }, { status: 401 }); - } - - const tokens = createSession(user); - - return HttpResponse.json(tokens, { - status: 200, - headers: { - "access-token": tokens["access-token"], - client: tokens.client, - uid: tokens.uid, - expiry: tokens.expiry, - "token-type": tokens["token-type"], - }, - }); - }), - - // POST /api/auth/logout - http.post("/api/auth/logout", ({ request }) => { - const accessToken = request.headers.get("access-token"); - const client = request.headers.get("client"); - - if (!client || !accessToken) { - return HttpResponse.json({ error: "Missing auth headers" }, { status: 400 }); - } - - deleteSession(client); - return HttpResponse.json({ message: "Logged out" }, { status: 200 }); - }), - - // POST /api/test/expire-token - http.post("/api/test/expire-token", ({ request }) => { - const client = request.headers.get("client"); - if (!client) { - return HttpResponse.json({ error: "Missing client header" }, { status: 400 }); - } - const expired = expireSession(client); - if (!expired) { - return HttpResponse.json({ error: "Session not found" }, { status: 404 }); - } - return HttpResponse.json({ message: "Token expired" }, { status: 200 }); - }), - - // GET /api/demo/info - http.get("/api/demo/info", ({ request }) => { - const accessToken = request.headers.get("access-token"); - const client = request.headers.get("client"); - - if (!client || !accessToken) { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const session = validateSession(client, accessToken); - if (!session) { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const user = findUserByUid(session.uid); - - const rotated = rotateTokens(client); - const headers: Record = {}; - if (rotated) { - headers["access-token"] = rotated["access-token"]; - headers.client = rotated.client; - headers.uid = rotated.uid; - headers.expiry = rotated.expiry; - headers["token-type"] = rotated["token-type"]; - } - - return HttpResponse.json( - { - authenticated: true, - principal: user?.username ?? session.uid, - authorities: ["ROLE_USER"], - authMethod: "Header", - message: "This endpoint accepts authentication via headers, cookies, or Bearer token", - }, - { status: 200, headers }, - ); - }), -]; diff --git a/sample/sample-react/src/pages/DashboardPage.tsx b/sample/sample-react/src/pages/DashboardPage.tsx deleted file mode 100644 index ad723fd..0000000 --- a/sample/sample-react/src/pages/DashboardPage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { useAuth } from "../auth/useAuth"; -import { useDemoInfo, useExpireToken, useLogout } from "../api/queries"; -import { TokenDisplay } from "../components/TokenDisplay"; -import { BackendBadge } from "../components/BackendBadge"; - -export function DashboardPage() { - const { tokens } = useAuth(); - const navigate = useNavigate(); - const demoInfo = useDemoInfo(); - const logout = useLogout(); - const expireToken = useExpireToken(); - - const handleLogout = () => { - logout.mutate(undefined, { - onSuccess: () => navigate("/login"), - }); - }; - - return ( -
-
-
-

Dashboard

-
- - -
-
- - {demoInfo.data && ( -
-

Session

-
-
Principal
-
{demoInfo.data.principal}
-
Auth Method
-
{demoInfo.data.authMethod}
-
Authorities
-
{demoInfo.data.authorities.join(", ")}
-
-
- )} - -
-

Token Rotation Demo

- -
- - -
- {demoInfo.data &&
{JSON.stringify(demoInfo.data, null, 2)}
} -
-
-
- ); -} diff --git a/sample/sample-react/src/pages/LoginPage.tsx b/sample/sample-react/src/pages/LoginPage.tsx deleted file mode 100644 index 43f4cdf..0000000 --- a/sample/sample-react/src/pages/LoginPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { Navigate, useNavigate } from "react-router-dom"; -import { useAuth } from "../auth/useAuth"; -import { useLogin } from "../api/queries"; - -export function LoginPage() { - const { isAuthenticated } = useAuth(); - const navigate = useNavigate(); - const login = useLogin(); - const [username, setUsername] = useState("user1@example.com"); - const [password, setPassword] = useState("password"); - - if (isAuthenticated) { - return ; - } - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - login.mutate( - { username, password }, - { - onSuccess: () => navigate("/dashboard"), - }, - ); - }; - - return ( -
-
-

Ogiri Auth Demo

-

Login to see token rotation in action

-
-
- - setUsername(e.target.value)} - placeholder="user1@example.com" - required - /> -
-
- - setPassword(e.target.value)} - placeholder="password" - required - /> -
- {login.error &&
{login.error.message}
} - -
-

Try user1@example.com / password or user2@example.com / password

-
-
- ); -} diff --git a/sample/sample-react/src/styles/app.css b/sample/sample-react/src/styles/app.css deleted file mode 100644 index b236e58..0000000 --- a/sample/sample-react/src/styles/app.css +++ /dev/null @@ -1,164 +0,0 @@ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f5f5f5; - color: #1a1a1a; - min-height: 100vh; -} - -.page { - display: flex; - justify-content: center; - align-items: flex-start; - padding: 4rem 1rem; - min-height: 100vh; -} - -.card { - background: #fff; - border-radius: 8px; - padding: 2rem; - width: 100%; - max-width: 560px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -h1 { - font-size: 1.5rem; - margin-bottom: 0.5rem; -} -h2 { - font-size: 1.1rem; - margin: 1.5rem 0 0.75rem; - color: #444; -} - -.field { - margin-bottom: 1rem; -} -.field label { - display: block; - margin-bottom: 0.25rem; - font-size: 0.875rem; - color: #555; -} -.field input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1rem; -} - -button { - padding: 0.5rem 1.25rem; - background: #2563eb; - color: #fff; - border: none; - border-radius: 4px; - font-size: 0.875rem; - cursor: pointer; -} -button:hover { - background: #1d4ed8; -} -button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.btn-secondary { - background: #6b7280; -} -.btn-secondary:hover { - background: #4b5563; -} - -.error { - color: #dc2626; - font-size: 0.875rem; - margin-bottom: 0.75rem; -} -.hint { - color: #888; - font-size: 0.8rem; - margin-top: 1rem; - text-align: center; -} -.muted { - color: #888; - font-size: 0.875rem; -} - -.header-row { - display: flex; - justify-content: space-between; - align-items: center; -} - -dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.25rem 1rem; -} -dt { - font-size: 0.8rem; - color: #888; - text-transform: uppercase; -} -dd { - font-size: 0.875rem; -} - -.token-display { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 4px; - padding: 0.75rem; - margin-bottom: 0.75rem; - transition: border-color 0.3s; -} -.token-display.rotated { - border-color: #22c55e; - background: #f0fdf4; -} - -.token-field { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.25rem; -} -.token-field:last-child { - margin-bottom: 0; -} -.token-label { - font-size: 0.75rem; - color: #888; - text-transform: uppercase; -} -.token-field code { - font-size: 0.8rem; - background: #e2e8f0; - padding: 0.125rem 0.375rem; - border-radius: 2px; -} - -.response { - background: #1e293b; - color: #e2e8f0; - padding: 0.75rem; - border-radius: 4px; - font-size: 0.8rem; - overflow-x: auto; - margin-top: 0.75rem; -} - -section { - margin-bottom: 1.5rem; -} diff --git a/sample/sample-react/tsconfig.json b/sample/sample-react/tsconfig.json deleted file mode 100644 index 6790d00..0000000 --- a/sample/sample-react/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "jsx": "react-jsx", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true - }, - "include": ["src"] -} diff --git a/sample/sample-react/tsconfig.tsbuildinfo b/sample/sample-react/tsconfig.tsbuildinfo deleted file mode 100644 index 2030b56..0000000 --- a/sample/sample-react/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/queries.ts","./src/auth/authprovider.tsx","./src/auth/protectedroute.tsx","./src/auth/useauth.ts","./src/components/tokendisplay.tsx","./src/lib/auth.ts","./src/lib/axios-ogiri.ts","./src/mocks/browser.ts","./src/mocks/db.test.ts","./src/mocks/db.ts","./src/mocks/handlers.ts","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/sample/sample-react/vite.config.ts b/sample/sample-react/vite.config.ts deleted file mode 100644 index 1f44852..0000000 --- a/sample/sample-react/vite.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig, loadEnv } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ""); - const apiTarget = env.VITE_API_TARGET; - - return { - plugins: [react()], - server: apiTarget - ? { proxy: { "/api": { target: apiTarget, changeOrigin: true } } } - : undefined, - }; -}); diff --git a/scripts/publish-docs.sh b/scripts/publish-docs.sh deleted file mode 100755 index 84ab95f..0000000 --- a/scripts/publish-docs.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -# Usage: ./scripts/publish-docs.sh [version] -# If no version provided, uses version from .ogiri-version - -set -e - -GREEN='\033[0;32m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' - -VERSION="${1:-.}" -if [ "$VERSION" = "." ]; then - VERSION=$(cat .ogiri-version) -fi - -VERSION=$(echo "$VERSION" | xargs) - -echo -e "${BLUE}Publishing documentation for version: $VERSION${NC}" - -if ! command -v mike &> /dev/null; then - echo -e "${RED}Error: 'mike' is not installed${NC}" - echo "Install with: pip install mkdocs-mike" - exit 1 -fi - -echo -e "${BLUE}Building documentation...${NC}" -export RELEASE_VERSION="$VERSION" -mkdocs build -q - -echo -e "${BLUE}Deploying version $VERSION to GitHub Pages...${NC}" -mike deploy --push --update-aliases "$VERSION" latest - -echo -e "${GREEN}✓ Documentation deployed successfully!${NC}" -echo -e "${GREEN}✓ Access at: https://quantipixels.github.io/ogiri${NC}" diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 3c67522..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/bash -# -# Usage: -# ./scripts/release.sh # Use version from .ogiri-version -# ./scripts/release.sh 1.0.3 # Use specific version -# ./scripts/release.sh -f 1.0.3 # Force reuse existing tag -# ./scripts/release.sh --force # Force reuse with version from .ogiri-version - -set -e - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" - -FORCE_REUSE=false -VERSION="" - -while [[ $# -gt 0 ]]; do - case $1 in - -f|--force) - FORCE_REUSE=true - shift - ;; - -*) - echo -e "${RED}Error: Unknown option '$1'${NC}" - exit 1 - ;; - *) - VERSION="$1" - shift - ;; - esac -done - -VERSION_FILE="$PROJECT_ROOT/.ogiri-version" - -if [ -n "$VERSION" ]; then - if [ ! -f "$VERSION_FILE" ]; then - echo -e "${RED}Error: .ogiri-version file not found${NC}" - exit 1 - fi - CURRENT_VERSION=$(cat "$VERSION_FILE" | tr -d '\n' | tr -d ' ') -else - if [ ! -f "$VERSION_FILE" ]; then - echo -e "${RED}Error: .ogiri-version file not found${NC}" - exit 1 - fi - VERSION=$(cat "$VERSION_FILE" | tr -d '\n' | tr -d ' ') -fi - -if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo -e "${RED}Error: Invalid version format '$VERSION'. Expected: X.Y.Z${NC}" - exit 1 -fi - -TAG="v$VERSION" - -echo -e "${YELLOW}Release Configuration:${NC}" -echo " Version: $VERSION" -echo " Tag: $TAG" -echo " Force reuse: $FORCE_REUSE" -echo " Project: $PROJECT_ROOT" -echo "" - -if git -C "$PROJECT_ROOT" rev-parse "$TAG" >/dev/null 2>&1; then - if [ "$FORCE_REUSE" = false ]; then - echo -e "${RED}Error: Tag '$TAG' already exists${NC}" - echo -e "${YELLOW}Use -f or --force to reuse/overwrite the tag${NC}" - exit 1 - fi -fi - -echo -e "${YELLOW}This will:${NC}" -if [ "$FORCE_REUSE" = true ]; then - echo " 1. Force update git tag: $TAG" - echo " 2. Force push tag to GitHub" -else - echo " 1. Create git tag: $TAG" - echo " 2. Push tag to GitHub" -fi -echo " 3. Trigger automated release workflow" -if [ -n "$CURRENT_VERSION" ] && [ "$VERSION" != "$CURRENT_VERSION" ]; then - echo " (.ogiri-version will be updated: $CURRENT_VERSION → $VERSION)" -else - echo " (.ogiri-version is already set to $VERSION)" -fi -echo "" -read -p "Continue? (y/N) " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo -e "${YELLOW}Release cancelled${NC}" - exit 0 -fi - -if [ -n "$CURRENT_VERSION" ] && [ "$VERSION" != "$CURRENT_VERSION" ]; then - echo "$VERSION" > "$VERSION_FILE" - echo -e "${GREEN}✓ .ogiri-version updated: $CURRENT_VERSION → $VERSION${NC}" -fi - -echo -e "${YELLOW}Creating/updating tag...${NC}" -if [ "$FORCE_REUSE" = true ]; then - git -C "$PROJECT_ROOT" tag -f -a "$TAG" -m "Release $VERSION" - echo -e "${GREEN}✓ Tag updated: $TAG${NC}" -else - git -C "$PROJECT_ROOT" tag -a "$TAG" -m "Release $VERSION" - echo -e "${GREEN}✓ Tag created: $TAG${NC}" -fi - -echo -e "${YELLOW}Pushing tag to GitHub...${NC}" -REMOTE="${GIT_REMOTE:-origin}" -if [ "$FORCE_REUSE" = true ]; then - git -C "$PROJECT_ROOT" push -f "$REMOTE" "$TAG" - echo -e "${GREEN}✓ Tag force-pushed to $REMOTE${NC}" -else - git -C "$PROJECT_ROOT" push "$REMOTE" "$TAG" - echo -e "${GREEN}✓ Tag pushed to $REMOTE${NC}" -fi - -echo "" -echo -e "${GREEN}Release initiated successfully!${NC}" -echo "" -echo "Release details:" -echo " GitHub: https://github.com/quantipixels/ogiri/releases/tag/$TAG" -echo " Workflow: https://github.com/quantipixels/ogiri/actions/workflows/release.yml" -echo "" -echo "The automated workflows will:" -echo " - release.yml: Create GitHub release" -echo " - release.yml (publish-npm): Publish ogiri-security-client to npm" -echo " - docs.yml: Build and deploy versioned documentation" -echo " - Maven workflows: Deploy independently (not blocked by each other)" -echo "" -echo "Check workflow status at: https://github.com/quantipixels/ogiri/actions" diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index 3bf25a7..0000000 --- a/settings.gradle.kts +++ /dev/null @@ -1,73 +0,0 @@ -rootProject.name = "ogiri" - -include(":ogiri-core") - -include(":ogiri-session-core") - -include(":ogiri-bom") - -include(":ogiri-test") - -include(":ogiri-jpa") - -include(":ogiri-jdbc") - -include(":ogiri-caffeine") - -include(":ogiri-redis") - -include(":sample:sample-java") - -include(":sample:sample-kotlin") - -/* - * Plugin repositories: - * Where Gradle downloads plugins declared in plugins {} blocks of build scripts. - * These repos apply only to plugin lookup, NOT library dependencies. - */ -plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } - -/* - * Dependency resolution configuration for ALL modules. - * - * Defines: - * - Global repositories (Maven Central, Local, custom) - * - Version catalogs (libs.*) - */ -dependencyResolutionManagement { - - /* - * Centralized repository definitions. - * - * These repositories apply to: - * - dependencies { implementation(...) } - * - dependencyManagement - * - test dependencies - * - version catalog dependencies - * - * Best practice: keep ALL repository declarations here. - */ - repositories { mavenCentral() } - - /* - * Version catalog: - * - * Provides a typed, IDE-aware way to define dependency versions and reuse them across modules. - * Example usage: - * implementation(libs.junit) - * version(libs.versions.kotlin.get()) - */ - versionCatalogs { - create("libs") { - // Plugin versions - version("kotlin", "2.1.20") - version("spotless", "8.0.0") - version("springBoot", "3.5.16") - version("dependencyManagement", "1.1.7") - version("versionsPlugin", "0.52.0") - version("caffeine", "3.2.4") - version("owasp", "12.2.2") - version("jacoco", "0.8.11") - } - } -} diff --git a/spotless.license.kt b/spotless.license.kt deleted file mode 100644 index defc21b..0000000 --- a/spotless.license.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright (c) $YEAR Quanti Pixels - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - */ diff --git a/test-samples.sh b/test-samples.sh deleted file mode 100755 index 2882356..0000000 --- a/test-samples.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/bin/bash - -set -e - -GREEN='\033[0;32m' -RED='\033[0;31m' -BLUE='\033[0;34m' -NC='\033[0m' - -function print_header() { - echo -e "\n${BLUE}=== $1 ===${NC}\n" -} - -function print_success() { - echo -e "${GREEN}✓ $1${NC}" -} - -function print_error() { - echo -e "${RED}✗ $1${NC}" -} - -function test_sample() { - local SAMPLE_NAME=$1 - local PORT=$2 - local BASE_URL="http://localhost:$PORT" - - print_header "Testing $SAMPLE_NAME Sample (port $PORT)" - - print_header "1. Health Check" - HEALTH=$(curl -s "$BASE_URL/api/health") - if [[ $HEALTH == *"UP"* ]]; then - print_success "Health endpoint: $HEALTH" - else - print_error "Health endpoint failed: $HEALTH" - return 1 - fi - - print_header "2. Login" - curl -s -X POST "$BASE_URL/api/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"username":"user1@example.com","password":"password"}' \ - -i > /tmp/login-response-$PORT.txt - - LOGIN_RESPONSE=$(tail -1 /tmp/login-response-$PORT.txt) - - if [[ $LOGIN_RESPONSE == *"accessToken"* ]]; then - print_success "Login successful" - echo "$LOGIN_RESPONSE" | python3 -m json.tool - else - print_error "Login failed: $LOGIN_RESPONSE" - return 1 - fi - - TOKEN=$(echo "$LOGIN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])") - CLIENT=$(echo "$LOGIN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['client'])") - USER_ID=$(echo "$LOGIN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['uid'])") - EXPIRY=$(echo "$LOGIN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['expiry'])") - - BEARER_HEADER=$(grep "Authorization:" /tmp/login-response-$PORT.txt | cut -d' ' -f2- | tr -d '\r') - - echo "Tokens extracted:" - echo " TOKEN: $TOKEN" - echo " CLIENT: $CLIENT" - echo " USER_ID: $USER_ID" - echo " EXPIRY: $EXPIRY" - echo " BEARER: ${BEARER_HEADER:0:50}... (truncated)" - - print_header "3. Authentication Method 1: HTTP Headers" - HEADERS_RESPONSE=$(curl -s "$BASE_URL/api/demo/headers" \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $USER_ID" \ - -H "expiry: $EXPIRY") - - if [[ $HEADERS_RESPONSE == *"authenticated"*"true"* ]]; then - print_success "Header-based authentication successful" - echo "$HEADERS_RESPONSE" | python3 -m json.tool - else - print_error "Header-based authentication failed: $HEADERS_RESPONSE" - fi - - print_header "4. Authentication Method 2: Secure Cookies" - COOKIES_RESPONSE=$(curl -s "$BASE_URL/api/demo/cookies" \ - -b "access-token=$TOKEN;client=$CLIENT;uid=$USER_ID;expiry=$EXPIRY") - - if [[ $COOKIES_RESPONSE == *"authenticated"*"true"* ]]; then - print_success "Cookie-based authentication successful" - echo "$COOKIES_RESPONSE" | python3 -m json.tool - else - print_error "Cookie-based authentication failed: $COOKIES_RESPONSE" - fi - - print_header "5. Authentication Method 3: Bearer Token" - BEARER_RESPONSE=$(curl -s "$BASE_URL/api/demo/bearer" \ - -H "Authorization: $BEARER_HEADER") - - if [[ $BEARER_RESPONSE == *"authenticated"*"true"* ]]; then - print_success "Bearer token authentication successful" - echo "$BEARER_RESPONSE" | python3 -m json.tool - elif [[ $BEARER_RESPONSE == *"500"* || $BEARER_RESPONSE == *"Internal Server Error"* ]]; then - print_error "Bearer token authentication failed with 500 error (known issue - missing messages.properties)" - echo "Response: $BEARER_RESPONSE" - else - print_error "Bearer token authentication failed: $BEARER_RESPONSE" - fi - - print_header "6. General Auth Info (works with any method)" - INFO_RESPONSE=$(curl -s "$BASE_URL/api/demo/info" \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $USER_ID" \ - -H "expiry: $EXPIRY") - - if [[ $INFO_RESPONSE == *"authenticated"* ]]; then - print_success "General auth endpoint successful" - echo "$INFO_RESPONSE" | python3 -m json.tool - else - print_error "General auth endpoint failed: $INFO_RESPONSE" - fi - - print_header "7. Current User Info (/api/me)" - ME_RESPONSE=$(curl -s "$BASE_URL/api/me" \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $USER_ID" \ - -H "expiry: $EXPIRY") - - if [[ $ME_RESPONSE == *"username"* ]]; then - print_success "Current user endpoint successful" - echo "$ME_RESPONSE" | python3 -m json.tool - else - print_error "Current user endpoint failed: $ME_RESPONSE" - fi - - print_header "8. Logout" - LOGOUT_RESPONSE=$(curl -s -X POST "$BASE_URL/api/auth/logout" \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $USER_ID" \ - -H "expiry: $EXPIRY") - - if [[ $LOGOUT_RESPONSE == *"Logout successful"* ]]; then - print_success "Logout successful" - echo "$LOGOUT_RESPONSE" | python3 -m json.tool - else - print_error "Logout failed: $LOGOUT_RESPONSE" - fi - - print_header "9. Verify token invalidated after logout" - AFTER_LOGOUT=$(curl -s "$BASE_URL/api/demo/headers" \ - -H "access-token: $TOKEN" \ - -H "client: $CLIENT" \ - -H "uid: $USER_ID" \ - -H "expiry: $EXPIRY") - - if [[ $AFTER_LOGOUT == *"authenticated"*"false"* || $AFTER_LOGOUT == *"Unauthorized"* ]]; then - print_success "Token correctly invalidated after logout" - else - print_error "Token still valid after logout: $AFTER_LOGOUT" - fi - - print_header "$SAMPLE_NAME Sample Tests Complete!" -} - -echo -e "${BLUE}" -echo "╔════════════════════════════════════════════════════╗" -echo "║ Ogiri Security Sample Application Test Suite ║" -echo "╚════════════════════════════════════════════════════╝" -echo -e "${NC}" - -test_sample "Java" 48080 -test_sample "Kotlin" 48081 - -echo "" -echo -e "${GREEN}All tests completed!${NC}" From 3a763b8305c7fdb41f5ded3779248ec7f28f4240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAwa=E1=B9=A3=C3=A8y=C3=AD?= <52331341+mosobande@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:12:21 +0100 Subject: [PATCH 02/13] ci: verify Maven artifacts and native HTTP integration on Java 17 and 25 --- .github/dependabot.yml | 14 +++ .github/workflows/build.yml | 107 ++++++++++++++----- .github/workflows/client-test.yml | 80 -------------- .github/workflows/codeql.yml | 42 ++++---- .github/workflows/docs-dev.yml | 39 ------- .github/workflows/lint.yml | 41 ------- .github/workflows/release.yml | 172 ------------------------------ .github/workflows/snapshot.yml | 79 -------------- .github/workflows/test.yml | 56 ---------- 9 files changed, 113 insertions(+), 517 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/client-test.yml delete mode 100644 .github/workflows/docs-dev.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/snapshot.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2745eb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + - package-ecosystem: maven + directory: /examples/spring-app + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a128786..3b42e10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,40 +1,95 @@ name: Build - on: - push: - branches: [ori] pull_request: - branches: [ori] - + push: + branches: [ori, build/ogiri-0.1.0] +permissions: + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - jobs: - build: + verify: + name: Java ${{ matrix.java }} / PostgreSQL ${{ matrix.postgres }} runs-on: ubuntu-latest timeout-minutes: 15 - + strategy: + fail-fast: false + matrix: + include: + - java: '17' + postgres: '16' + - java: '25' + postgres: '18' + services: + postgres: + image: postgres:${{ matrix.postgres }} + env: + POSTGRES_USER: ogiri + POSTGRES_PASSWORD: ogiri + POSTGRES_DB: ogiri_test + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U ogiri -d ogiri_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + OGIRI_TEST_JDBC_URL: jdbc:postgresql://localhost:5432/ogiri_test + OGIRI_TEST_JDBC_USER: ogiri + OGIRI_TEST_JDBC_PASSWORD: ogiri steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: - java-version: 17 - distribution: "temurin" - cache: gradle - - - name: Build with Gradle - run: ./gradlew clean build -x test --no-daemon - - - name: Upload build artifacts + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + - name: Verify and install the two library artifacts + run: mvn --batch-mode --no-transfer-progress clean install + - name: Apply the PostgreSQL schema from the built JAR + env: + PGPASSWORD: ogiri + run: | + set -euo pipefail + psql -h localhost -U ogiri -d ogiri_test -v ON_ERROR_STOP=1 -c 'DROP TABLE ogiri_sessions' + unzip -p ogiri/target/ogiri-0.1.0.jar META-INF/ogiri/schema-postgresql.sql | psql -h localhost -U ogiri -d ogiri_test -v ON_ERROR_STOP=1 + - name: Verify the independently resolved consumer over real HTTP + run: mvn --batch-mode --no-transfer-progress -f examples/spring-app/pom.xml verify + - name: Inspect packaged API documentation and test evidence + run: | + python - <<'PY' + from pathlib import Path + from zipfile import ZipFile + import json, xml.etree.ElementTree as E + results=[] + for module in ('ogiri','ogiri-spring-security','examples/spring-app'): + reports=list(Path(module).glob('target/surefire-reports/TEST-*.xml')) + assert reports, f'Missing test evidence: {module}' + for report in reports: + root=E.parse(report).getroot() + counts={key:int(root.get(key,0)) for key in ('tests','failures','errors','skipped')} + assert counts['tests'] and not any(counts[k] for k in ('failures','errors','skipped')), (module,counts) + results.append({'module':module,'suite':root.get('name'),**counts}) + for module in ('ogiri','ogiri-spring-security'): + with ZipFile(f'{module}/target/{module}-0.1.0-javadoc.jar') as jar: + assert any(name.endswith('.html') for name in jar.namelist()), module + with ZipFile(f'{module}/target/{module}-0.1.0-sources.jar') as jar: + assert any(name.endswith('.java') for name in jar.namelist()), module + with ZipFile('ogiri/target/ogiri-0.1.0.jar') as jar: + assert 'META-INF/ogiri/schema-postgresql.sql' in jar.namelist() + assert not any(name.startswith('db/migration/') or name.endswith('.kotlin_module') for name in jar.namelist()) + Path('target').mkdir(exist_ok=True) + Path('target/verification.json').write_text(json.dumps(results,indent=2)+'\n') + print(json.dumps(results,indent=2)) + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: build-artifacts - path: "**/build/libs/*.jar" - retention-days: 1 + name: verification-java${{ matrix.java }}-postgres${{ matrix.postgres }} + path: | + target/verification.json + **/target/surefire-reports/TEST-*.xml + ogiri/target/*.jar + ogiri-spring-security/target/*.jar + retention-days: 7 diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml deleted file mode 100644 index 40a36d1..0000000 --- a/.github/workflows/client-test.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Client Test - -on: - push: - branches: [ori, main] - paths: - - "ogiri-client/**" - - ".github/workflows/client-test.yml" - pull_request: - branches: [ori, main] - paths: - - "ogiri-client/**" - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - matrix: - node-version: [22, 24] - - defaults: - run: - working-directory: ogiri-client - - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - - - name: Setup pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 - with: - version: 10 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: ${{ matrix.node-version }} - cache: "pnpm" - cache-dependency-path: ogiri-client/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-workspace - - - name: Type check - run: pnpm typecheck - - - name: Run tests - run: pnpm vitest run --coverage - - - name: Upload coverage reports - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 - if: matrix.node-version == 24 - with: - files: ./ogiri-client/coverage/lcov.info - flags: typescript-client - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - - name: Build - run: pnpm build - - - name: Check build artifacts - run: | - if [ ! -f dist/index.js ]; then - echo "Build failed: dist/index.js not found" - exit 1 - fi - if [ ! -f dist/index.d.ts ]; then - echo "Build failed: dist/index.d.ts not found" - exit 1 - fi - echo "Build artifacts verified" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 40bd0e5..aaf546d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,41 +1,35 @@ name: CodeQL - on: pull_request: push: - branches: [ori] + branches: [ori, build/ogiri-0.1.0] schedule: - - cron: "17 3 * * 1" - + - cron: '17 3 * * 1' permissions: contents: read security-events: write - +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: analyze: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - - - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + with: + distribution: temurin + java-version: '17' + cache: maven + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: languages: java-kotlin build-mode: manual - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 - with: - java-version: 17 - distribution: temurin - cache: gradle - - - name: Build - run: ./gradlew assemble --no-daemon - - - name: Analyze - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + - name: Compile library and standalone application for analysis + run: | + mvn --batch-mode --no-transfer-progress -Dmaven.test.skip=true -Dmaven.javadoc.skip=true install + mvn --batch-mode --no-transfer-progress -f examples/spring-app/pom.xml -Dmaven.test.skip=true package + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml deleted file mode 100644 index cb30c77..0000000 --- a/.github/workflows/docs-dev.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy Dev Docs - -on: - push: - branches: [ori] - tags-ignore: ["**"] - paths: - - "docs/**" - - "mkdocs.yml" - - ".github/workflows/docs-dev.yml" - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - fetch-depth: 0 - - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - cache: "pip" - - - run: pip install -e . - - - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - run: mike deploy --push dev diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 1f8b908..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Lint - -on: - push: - branches: [ori] - pull_request: - branches: [ori] - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 - with: - java-version: 17 - distribution: "temurin" - cache: gradle - - - name: Check code formatting with Spotless - run: ./gradlew spotlessCheck --no-daemon - - - name: Show formatting violations (if any) - if: failure() - run: | - echo "::error::Code formatting violations detected. Run 'gradlew spotlessApply' locally to fix." - ./gradlew spotlessApply --no-daemon - echo "" - echo "Expected changes:" - git diff --color=always || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index cefc3b0..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,172 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*.*.*" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout immutable tag - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 - with: - java-version: 17 - distribution: temurin - cache: gradle - - - name: Verify source, tests, coverage, consumers, and dependencies - run: ./gradlew clean check dependencyCheckAnalyze --no-daemon - - - name: Stage every publication locally - run: ./gradlew publishToMavenLocal --no-daemon - - publish: - needs: verify - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout immutable tag - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 - with: - java-version: 17 - distribution: temurin - cache: gradle - - - name: Extract immutable tag version - id: version - shell: bash - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - - name: Validate Central Portal and signing secrets - shell: bash - env: - CENTRAL_USERNAME: ${{ secrets.OSSRH_USERNAME }} - CENTRAL_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} - GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: | - set -euo pipefail - for name in CENTRAL_USERNAME CENTRAL_PASSWORD GPG_PRIVATE_KEY GPG_PASSPHRASE; do - if [ -z "${!name:-}" ]; then - echo "::error::Required release secret is missing: $name" - exit 1 - fi - done - - - name: Import signing key - uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - passphrase: ${{ secrets.GPG_PASSPHRASE }} - - - name: Publish signed artifacts - run: ./gradlew publish --no-daemon - env: - RELEASE_VERSION: ${{ steps.version.outputs.version }} - ORG_GRADLE_PROJECT_ossrhUsername: ${{ secrets.OSSRH_USERNAME }} - ORG_GRADLE_PROJECT_ossrhPassword: ${{ secrets.OSSRH_PASSWORD }} - ORG_GRADLE_PROJECT_signing.key: ${{ secrets.GPG_PRIVATE_KEY }} - ORG_GRADLE_PROJECT_signing.password: ${{ secrets.GPG_PASSPHRASE }} - - - name: Transfer deployment to Central Portal - shell: bash - env: - CENTRAL_USERNAME: ${{ secrets.OSSRH_USERNAME }} - CENTRAL_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} - run: | - set -euo pipefail - authorization="$(printf '%s:%s' "$CENTRAL_USERNAME" "$CENTRAL_PASSWORD" | base64 | tr -d '\n')" - curl \ - --fail \ - --silent \ - --show-error \ - --request POST \ - --header "Authorization: Bearer $authorization" \ - "https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/com.quantipixels?publishing_type=automatic" - - - name: Verify every published module resolves - shell: bash - run: | - set -euo pipefail - version='${{ steps.version.outputs.version }}' - modules=(ogiri-bom ogiri-session-core ogiri-core ogiri-jpa ogiri-jdbc ogiri-caffeine ogiri-redis ogiri-test) - for attempt in $(seq 1 15); do - missing=0 - for module in "${modules[@]}"; do - url="https://repo1.maven.org/maven2/com/quantipixels/ogiri/${module}/${version}/${module}-${version}.pom" - curl --fail --silent --show-error --head "$url" >/dev/null || missing=1 - done - if [ "$missing" -eq 0 ]; then exit 0; fi - sleep 60 - done - echo "::error::Not every module resolved from Maven Central" - exit 1 - - release: - needs: publish - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Create release after artifact verification - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - generate_release_notes: true - name: Release ${{ needs.publish.outputs.version }} - draft: false - prerelease: false - - deploy-docs: - needs: release - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Checkout immutable tag - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.12" - cache: pip - - - run: pip install -e . - - - name: Deploy versioned documentation - shell: bash - run: | - set -euo pipefail - minor="$(printf '%s' "${GITHUB_REF_NAME#v}" | grep -oE '^[0-9]+\.[0-9]+')" - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - mike deploy --push --update-aliases "$minor" latest - mike set-default --push latest diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml deleted file mode 100644 index 6b14b3f..0000000 --- a/.github/workflows/snapshot.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Snapshot Deployment - -on: - push: - branches: [ori] - paths: - - "ogiri-core/**" - - "ogiri-jpa/**" - - "ogiri-session-core/**" - - "ogiri-bom/**" - - "ogiri-test/**" - - "ogiri-jdbc/**" - - "ogiri-caffeine/**" - - "ogiri-redis/**" - - "build.gradle.kts" - - "settings.gradle.kts" - - "gradle/**" - - ".github/workflows/snapshot.yml" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - snapshot: - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - packages: write - - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 - with: - java-version: 17 - distribution: "temurin" - cache: gradle - - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - passphrase: ${{ secrets.GPG_PASSPHRASE }} - - - name: Determine snapshot version - id: snapshot-version - run: | - VERSION=$(grep 'projectVersion =' settings.gradle.kts | grep -o '"[^"]*"' | tr -d '"' || true) - - if [ -z "$VERSION" ] && [ -f ".ogiri-version" ]; then - VERSION=$(cat .ogiri-version | tr -d '[:space:]') - fi - - if [ -z "$VERSION" ]; then - echo "::error::Failed to resolve project version from settings.gradle.kts or .ogiri-version" - exit 1 - fi - - SNAPSHOT_VERSION="${VERSION%-SNAPSHOT}-SNAPSHOT" - echo "VERSION=$SNAPSHOT_VERSION" >> $GITHUB_OUTPUT - - - name: Deploy snapshots to Maven Central - run: ./gradlew publish --no-daemon - env: - RELEASE_VERSION: ${{ steps.snapshot-version.outputs.VERSION }} - ORG_GRADLE_PROJECT_ossrhUsername: ${{ secrets.OSSRH_USERNAME }} - ORG_GRADLE_PROJECT_ossrhPassword: ${{ secrets.OSSRH_PASSWORD }} - ORG_GRADLE_PROJECT_signing.key: ${{ secrets.GPG_PRIVATE_KEY }} - ORG_GRADLE_PROJECT_signing.password: ${{ secrets.GPG_PASSPHRASE }} - - - name: Notify snapshot deployment - if: success() - run: echo "Snapshot deployed successfully to Maven Central" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 3c86444..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Test - -on: - push: - branches: [ori] - pull_request: - branches: [ori] - -permissions: - contents: read - id-token: write - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 - with: - java-version: 17 - distribution: "temurin" - cache: gradle - - - name: Run tests with Gradle - run: ./gradlew test --no-daemon - - - name: Generate test report - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: test-results - path: | - ogiri-core/build/test-results/ - ogiri-jpa/build/test-results/ - sample/*/build/test-results/ - retention-days: 30 - - - name: Upload coverage to Codecov - if: always() - uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 - with: - files: ./ogiri-core/build/reports/jacoco/test/jacocoTestReport.xml,./ogiri-jpa/build/reports/jacoco/test/jacocoTestReport.xml - flags: unittests - fail_ci_if_error: false - use_oidc: true From d225874ce89fdc31ec913378165fe3f893e9a41b Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 08:12:20 +0000 Subject: [PATCH 03/13] feat: integrate Spring Boot authentication with PostgreSQL and MySQL sessions --- CONTRIBUTING.md | 30 ++- README.md | 117 +++++------ SECURITY.md | 34 ++-- examples/spring-app/README.md | 16 +- examples/spring-app/pom.xml | 8 +- .../main/java/example/ogiri/Application.java | 117 +---------- .../test/java/example/ogiri/ConsumerTest.java | 55 ++--- .../java/example/ogiri/HostChainTest.java | 81 ++++++++ ogiri-spring-boot-starter/pom.xml | 21 ++ .../ogiri/spring/OgiriAccounts.java | 14 ++ .../ogiri/spring/OgiriAutoConfiguration.java | 84 ++++++++ .../ogiri/spring/OgiriEndpoints.java | 93 +++++++++ .../spring/OgiriOpaqueTokenIntrospector.java | 8 +- .../ogiri/spring/OgiriProperties.java | 34 ++++ .../ogiri/spring/OgiriSecurity.java | 64 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../spring/OgiriAutoConfigurationTest.java | 47 +++++ .../OgiriOpaqueTokenIntrospectorTest.java | 22 +- ogiri-spring-security/pom.xml | 12 -- ogiri/pom.xml | 5 +- .../java/com/quantipixels/ogiri/Database.java | 33 +++ .../com/quantipixels/ogiri/JdbcSessions.java | 145 ++++++++++++++ .../quantipixels/ogiri/PostgresSessions.java | 189 ------------------ .../ogiri/SessionStoreException.java | 2 +- .../java/com/quantipixels/ogiri/Tokens.java | 9 +- .../resources/META-INF/ogiri/schema-mysql.sql | 17 ++ .../META-INF/ogiri/schema-postgresql.sql | 11 +- ...essionsTest.java => JdbcSessionsTest.java} | 114 ++++++----- pom.xml | 8 +- 29 files changed, 873 insertions(+), 518 deletions(-) create mode 100644 examples/spring-app/src/test/java/example/ogiri/HostChainTest.java create mode 100644 ogiri-spring-boot-starter/pom.xml create mode 100644 ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAccounts.java create mode 100644 ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java create mode 100644 ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriEndpoints.java rename {ogiri-spring-security => ogiri-spring-boot-starter}/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java (90%) create mode 100644 ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriProperties.java create mode 100644 ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriSecurity.java create mode 100644 ogiri-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java rename {ogiri-spring-security => ogiri-spring-boot-starter}/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java (84%) delete mode 100644 ogiri-spring-security/pom.xml create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/Database.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java delete mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java create mode 100644 ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql rename ogiri/src/test/java/com/quantipixels/ogiri/{PostgresSessionsTest.java => JdbcSessionsTest.java} (67%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9029e3..6aead23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,38 @@ # Contributing -Use Java 17 or newer, Maven 3.9+, and a disposable PostgreSQL database. Tests deliberately fail rather than skip when the database is missing. They drop and recreate `ogiri_sessions`; **never point them at a development database containing valuable data or at production**. +Use Java 17+, Maven 3.9+ and a **disposable** PostgreSQL or MySQL database. Tests drop/recreate `ogiri_sessions` and `ogiri_subject_locks`. Never use a valuable development or production database. ```sh -docker run --rm --name ogiri-test -e POSTGRES_USER=ogiri -e POSTGRES_PASSWORD=ogiri -e POSTGRES_DB=ogiri_test -p 127.0.0.1:5432:5432 -d postgres:16 +export OGIRI_TEST_DATABASE=postgresql # or mysql export OGIRI_TEST_JDBC_URL=jdbc:postgresql://localhost:5432/ogiri_test export OGIRI_TEST_JDBC_USER=ogiri export OGIRI_TEST_JDBC_PASSWORD=ogiri mvn --batch-mode --no-transfer-progress clean install mvn --batch-mode --no-transfer-progress -f examples/spring-app/pom.xml verify +# MySQL consumer: add -Pmysql; use jdbc:mysql://localhost:3306/ogiri_test ``` -The root build tests and installs the two code artifacts and their parent POM locally. The separate consumer verifies the installed dependency graph and real HTTP behaviour. CI also extracts and applies the exact SQL template from the built JAR before the consumer test. No Maven Central deployment is part of `install` or CI. +The library suites apply packaged schemas using Spring's ResourceDatabasePopulator. The independent example consumes installed Maven artifacts, not reactor sources. CI runs each database in isolation; never run suites concurrently against the same schema. -## Test admission +Each test needs a material contract, stable seam, independent oracle, plausible wrong implementation and coverage gap. Preserve real SQL/concurrency tests. Do not add getter tests, private call-order mocks, sleeps-as-clocks or coverage quotas. Mock external faults only when real failures cannot be induced reliably. A smaller implementation that transfers shared security work to every consumer is not a simplification. -Every retained test must protect a material public contract, use an independent outcome oracle and name a plausible wrong implementation it would reject. Prefer the real PostgreSQL behaviour for transaction, lock, expiry and SQL claims. Test doubles are appropriate for an external fault that cannot be reliably induced otherwise, such as a controlled pre-commit failure or account-directory outage; they are not replacement stores. +Keep ordinary guidance here, in README/Javadoc or SECURITY. Reports/temporary mutation evidence belong in CI/PR artifacts, not a permanent audit archive. Make coherent logical commits and non-force pushes. -Do not add record-getter tests, mock call-order checks, assertions on private helpers, blanket coverage quotas, sleeps as clocks, or tests that restate implementation text. Reuse stronger existing coverage. Remove construction-history tests once a stronger public scenario subsumes them. A green suite is not sufficient evidence: challenge security predicates and resource boundaries with a focused mutation or negative control when practical. +## Performance and dependencies -## Changes +After tests create the disposable schema, run the storage benchmark explicitly: -Keep account policy in the application, session invariants in the core and framework transport in Spring Security. A new module, configuration switch, dependency, provider interface or persistent state field needs a concrete current consumer. Update the owning README/Javadoc/security section rather than adding an audit-report archive. Make coherent logical commits and non-force pushes; do not edit version tags or applied application migrations. +```sh +mvn -pl ogiri org.codehaus.mojo:exec-maven-plugin:3.5.0:java -Dexec.mainClass=com.quantipixels.ogiri.JdbcSessionsBenchmark -Dexec.classpathScope=test +``` + +It seeds 10,000 rows, warms the path, then measures one and eight concurrent readers through Hikari. Output is `ogiri/target/benchmark-.json`. This measures local storage authentication, not password login, account-directory latency, HTTP or production capacity. It has no pass/fail latency threshold. It uses the disposable test variables and removes only its benchmark realm's rows. + +Generate a resolved runtime SBOM for scanning with the official OSV scanner: + +```sh +mvn org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DincludeTestScope=false +osv-scanner scan source --sbom=target/bom.json +``` + +Do not suppress a vulnerability to make CI green. Distinguish database errors/unavailable advisory services from a completed clean scan. New database claims require the same behavioural tests, not H2 compatibility mode. diff --git a/README.md b/README.md index e3ca6f6..45c00eb 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,99 @@ # Ogiri 0.1.0 -Revocable opaque sessions for applications that already own their accounts and use PostgreSQL. +Low-setup opaque bearer sessions for **Spring Boot 4.1 / Java 17+**, with **PostgreSQL and MySQL 8+**. Keep your accounts, password encoder, identity model and authorization rules. Ogiri provides the reusable session lifecycle and Boot integration. -Ogiri issues credentials, verifies them, enforces a per-account session limit, lists devices, revokes sessions and removes expired rows. It does not own login, passwords, cookies, HTTP endpoints or your security chain. The core is plain Java 17 with no third-party runtime dependencies. The optional adapter implements Spring Security's `OpaqueTokenIntrospector`. +This is an unpublished greenfield API. The earlier PostgreSQL-only 0.1.0 candidate is superseded: `JdbcSessions` replaces `PostgresSessions`, the adapter becomes a Boot starter, and the schema changes. Do not mix old/new binaries or schema. No v3/v4 credential migration is implied. -**This is an unpublished greenfield 0.1.0 API, not a compatible downgrade from the old v3/v4 design.** Old tags and branches remain history, not this library's release lineage. Do not mix old and new schemas or credentials. - -## Is this the right library? - -Use Spring Security with [Spring Session JDBC](https://docs.spring.io/spring-session/reference/configuration/jdbc.html) for ordinary browser `HttpSession` applications. Use an identity provider and Spring Security's resource server for OAuth/OIDC federation. Ogiri is useful when you specifically need application-issued, independently revocable bearer sessions whose plaintext credentials are absent from PostgreSQL. - -There is no refresh-token protocol, automatic rotation, idle timeout, cookie transport, distributed rate limiter, account registration or password-recovery system. Those are deliberate scope decisions, not hidden unfinished adapters. See [security boundaries](SECURITY.md) before adoption. - -## Install locally - -Until a release is published, build the repository with a disposable PostgreSQL database as described in [CONTRIBUTING.md](CONTRIBUTING.md). `mvn clean install` installs the actual artifacts into your local Maven repository; it does not publish to Central. +## Install ```xml com.quantipixels.ogiri - ogiri + ogiri-spring-boot-starter 0.1.0 ``` -Use `ogiri-spring-security` instead to include the Spring adapter and core. There are two code artifacts and one parent POM, not a BOM or a family of speculative stores. Supply your own PostgreSQL JDBC driver and connection pool. Both code artifacts have sources and generated Javadoc. +Until published, run `mvn clean install` with a disposable database (see [CONTRIBUTING.md](CONTRIBUTING.md)). Add **one** database driver: `org.postgresql:postgresql` or `com.mysql:mysql-connector-j`. Your application's Spring Boot BOM manages its version. The starter includes Spring JDBC, the native OAuth2 resource-server pipeline, and Spring MVC; it does not require JPA, Redis or a Kotlin runtime. -## Provision the schema +Configure your ordinary `spring.datasource.*` settings and provision the schema. Provide your existing `UserDetailsService`; no Ogiri user entity, session repository, authentication manager, filter or controller is needed for the default path: -Copy `META-INF/ogiri/schema-postgresql.sql` from the core JAR into an application-owned migration. The library never creates tables or registers a Flyway migration. Configure the pool's PostgreSQL `search_path` to the schema containing `ogiri_sessions`; do not let untrusted accounts create objects there. - -```sh -unzip -p ogiri/target/ogiri-0.1.0.jar META-INF/ogiri/schema-postgresql.sql > schema.sql -psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql +```java +@Bean +UserDetailsService users(UserRepository repository) { + return username -> repository.securityUser(username) + .orElseThrow(() -> new UsernameNotFoundException("Unknown account")); +} ``` -The schema has one table and indexes for token lookup, account management and expiry cleanup. Identity columns use exact, case-sensitive `C` collation. Apply schema changes through your migrations, not runtime `create-drop`. +`UserRepository` and `securityUser` belong to your application. Return Spring `UserDetails`. The default adapter uses the immutable username as the stable account ID in realm `users`, with no tenant. For mutable usernames, opaque IDs or tenants, supply `OgiriAccounts`: `subject(Authentication)` resolves successful login to a stable full identity; `load(Subject)` loads current status/authorities from that identity. Never resolve an ID as a mutable login accidentally. -## Use the lifecycle +Spring's `AuthenticationConfiguration` builds password authentication from your normal services/providers; an application `AuthenticationManager` or `PasswordEncoder` wins. Without your own encoder Ogiri uses Spring's delegating password encoder, so stored hashes need their algorithm prefix (for example `{bcrypt}`). No default users or passwords are created by Ogiri. -```java -var sessions = new PostgresSessions(dataSource); -var owner = new Subject("customers", "tenant-42", "immutable-account-id"); - -// Only after the application has authenticated and authorized this full identity. -var issued = sessions.issue(owner, "Personal phone"); -String credential = issued.token(); // Deliver explicitly, over TLS. Never log it. - -Optional authenticated = sessions.authenticate(credential); -List devices = sessions.list(owner); -sessions.revoke(owner, issued.session().id()); -sessions.revokeAll(owner); -int removed = sessions.cleanup(500); -``` +## Endpoints and defaults + +With no application `SecurityFilterChain`, the starter supplies a stateless bearer chain. Only the configured JSON sign-in is public; other routes require authentication. Add method/route authorization for your business rules. + +| Request | Outcome | +| --- | --- | +| `POST /auth/sign-in` | Authenticate JSON `username`, `password`, `client`; return session metadata and an `Authorization: Bearer ...` header | +| `GET /auth/session` | Current session metadata | +| `GET /auth/sessions` | List this account's live sessions | +| `DELETE /auth/sessions/{id}` | Revoke one owned session | +| `DELETE /auth/sign-out` | Revoke the current session | +| `DELETE /auth/sessions` | Revoke all current account sessions | -The default lifetime is **seven days**, with at most **ten live sessions per account**. Configure both explicitly with `new SessionPolicy(Duration.ofHours(12), 5)`. Reaching the cap throws `SessionLimitException`; Ogiri does not silently evict another device. All instances sharing the table must use the same policy. +Send `Content-Type: application/json` and `X-Requested-With: Ogiri` on sign-in. That non-simple request is the only login CSRF exemption; other writes retain Spring's CSRF handling. Restrict CORS to trusted origins. Bearer credentials go in the Authorization header, never URLs, response JSON or logs. Native resource-server authentication plus duplicate-header rejection is packaged in the library, not copied from a demo. -`Subject` is the tuple `(realm, tenantId, subjectId)`. Realm is an identity namespace, not an OAuth audience. An empty tenant ID means non-tenanted, never all tenants. Subject IDs must be stable; do not use a mutable email address or login name. The session UUID identifies a device record; it cannot authenticate. The client string is an untrusted display label, not a device identifier or authorization scope. +```yaml +ogiri: + lifetime: 7d + maximum-sessions: 10 + realm: users + base-path: /auth + endpoints-enabled: true + enabled: true +``` + +All settings above show defaults, not required configuration. Properties are bound and validated by Boot, with generated IDE metadata. Reaching the session cap rejects the new issuance; it does not evict another device. Expiry is fixed, not sliding. Session reads check current account status and authorities. Invalid credentials fail authentication; directory/storage outages remain failures, never fabricated anonymous success. -## Integrate with Spring Security +## Keep an existing security chain -Provide the native introspector in the security chain your application already owns: +Ogiri backs off completely from creating a chain when **any** application chain exists. Inject the reusable `OgiriSecurity` helper into whichever chains should accept Ogiri tokens: ```java @Bean -OpaqueTokenIntrospector introspector(PostgresSessions sessions, AccountDirectory accounts) { - return new OgiriOpaqueTokenIntrospector(sessions, subject -> - accounts.loadSecurityUser(subject.realm(), subject.tenantId(), subject.subjectId())); +SecurityFilterChain security(HttpSecurity http, OgiriSecurity ogiri) throws Exception { + ogiri.configure(http); // Native bearer authentication, not authorization or global CSRF disable. + return http + .authorizeHttpRequests(routes -> routes + .requestMatchers(ogiri.signInRequest()).permitAll() + .anyRequest().authenticated()) + .csrf(csrf -> csrf.ignoringRequestMatchers(ogiri.signInRequest())) + .build(); } ``` -`AccountDirectory` is your application's existing account adapter, returning Spring `UserDetails`. It must validate the entire identity and any permitted realm/tenant context. Ogiri checks account status and uses its current authorities on every authenticated request; it never assumes that an account ID is a username. +Retain your existing request matchers, other authentication methods, error-dispatch handling and CSRF policy. Add the sign-in permit/exemption only when you use the built-in login. The helper is reusable across multiple chains; it is not a mutable singleton configurer. `ogiri.endpoints-enabled=false` removes the controller, and `ogiri.enabled=false` disables all Ogiri auto-configuration. Business authorization remains yours. Built-in management accepts an Ogiri session, not an unrelated Basic/JWT principal. -```java -http.oauth2ResourceServer(resource -> - resource.opaqueToken(opaque -> opaque.introspector(introspector))); -``` +## Database ownership -Use the native [Spring Security bearer-token pipeline](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/opaque-token.html). Ogiri registers no filters, endpoints, bean auto-configuration or global CSRF rules. Keep your other authentication mechanisms and authorization rules in their existing owner. +Copy `META-INF/ogiri/schema-postgresql.sql` or `META-INF/ogiri/schema-mysql.sql` from the core JAR into an application-owned migration. Ogiri never reserves a Flyway version, runs DDL at startup, or modifies application tables. Spring Boot SQL initialization may be used explicitly in disposable development databases. Production migrations are application-owned. -The [standalone Spring Boot example](examples/spring-app) demonstrates password sign-in, native bearer authentication, roles, scoped device management and revocation. Its Maven build consumes installed artifacts rather than reactor source dependencies. It runs real HTTP requests against a real PostgreSQL database, including a one-connection pool. The application example rejects duplicate Authorization headers before native bearer parsing and preserves error status through ERROR redispatches. +Both engines use the same lifecycle, admission rules and contract suite. SQL variations are limited to timestamp expressions, lock-row insertion and DDL types. Schema timestamps are UTC epoch milliseconds. Identity uses exact, case-sensitive, non-padding comparisons, including on MySQL. MySQL tables must use InnoDB. -## Operations and consistency +Two tables are required. `ogiri_sessions` holds hashes and metadata. `ogiri_subject_locks` provides stable row locks for admission and account-wide revocation. Lock rows retain one digest per identity; do not delete them while writers run, as that can split the serialization boundary. They contain no credential or direct identity text. This retained state is the explicit cost of portable session-cap enforcement, not a cache. -Each authentication performs one indexed read and no writes. Fixed expiry uses the database statement-start timestamp; activity does not extend it. Issuance reads that timestamp only after acquiring its account lock, so lock waits do not backdate a new session. Admission uses a transaction-scoped PostgreSQL advisory lock, count and insert; revocation is immediate for authoritative reads that begin after commit. Requests already authorized are not retroactively cancelled. +Use the authoritative primary and a normal underlying pool, not a transaction-aware or replica-routing proxy. Spring `JdbcTransactionManager` and `TransactionTemplate` own commit, rollback and resource restoration. Session mutations commit in independent `REQUIRES_NEW` transactions; reads suspend an outer JDBC transaction so a stale snapshot cannot restore revoked credentials. An outer transaction that already holds a connection needs spare pool capacity. Ordinary calls work with a one-connection pool. No automatic retry pretends to resolve an ambiguous commit. -Mutations commit before returning. Supply a normal pool with auto-commit connections, not a transaction-bound `DataSource` proxy. Ogiri rejects already enlisted connections, including for reads. Calls are independent of your application's transactions: an outer rollback does not roll back an already committed session mutation. An outer transaction holding a connection therefore needs additional pool capacity. +Each authentication performs one indexed session read, no writes and no positive cache, followed by the current account lookup in the Spring adapter. Malformed credentials fail before query execution. Five-second SQL timeouts do not replace connection, socket or HTTP timeouts. Configure those through your pool/server. Authentication expiry does not depend on cleanup. -Queries have a five-second timeout. Configure connection acquisition, socket timeouts, TLS and pool sizing on the supplied data source. Database availability is required; there is no stale authentication cache. An invalid token returns `Optional.empty()`; storage failures throw `SessionStoreException` and must not be treated as anonymous success. +Schedule `JdbcSessions.cleanup(batchSize)` in your existing jobs. It locks a bounded ID page with `SKIP LOCKED` and deletes it in the same transaction; concurrent workers need no leader lease. Limit job runtime and stop when fewer than a page is returned. Core-only callers can depend on `ogiri` and construct `JdbcSessions(dataSource, policy)` without Boot. -Schedule bounded `cleanup(batchSize)` calls in your existing jobs. `SKIP LOCKED` permits multiple workers without a leader lease. Cleanup is not authentication expiry enforcement: expired tokens are rejected even when cleanup is delayed. Stop paging when the returned count is below your page size, and use your job's run budget. +## Deliberate limits -## Deliberate limits and revisit triggers +No refresh/rotation protocol, cookie transport, registration, recovery orchestration or MFA is invented. Ordinary browser HttpSession applications should consider Spring Session JDBC; federated OAuth/OIDC should use an identity provider. These tools are complementary, not reimplemented here. See [SECURITY.md](SECURITY.md) for recovery coordination and token-lifetime trade-offs, and [PUBLISHING.md](PUBLISHING.md) for the opt-in Central release path. -Revisit fixed lifetime when measured re-authentication friction requires renewal or your threat model requires shorter credential exposure. Revisit PostgreSQL-only storage only for a concrete adopter with a different store and equivalent atomicity proofs. Revisit direct SQL or the absence of caching only after production query/latency measurements; do not cache revocation away. Revisit the passive Spring adapter only if repeated consumer code proves a genuinely shared, safe policy rather than application-specific login behaviour. +The [independent example](examples/spring-app) consumes the actual installed artifacts. It exercises both the zero-plumbing default and existing multi-chain applications against both database engines. Production performance is not inferred from line counts; the opt-in benchmark measures a defined local storage workload. Licensed under Apache-2.0. diff --git a/SECURITY.md b/SECURITY.md index 96dbebd..0bc7ba4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,35 +1,35 @@ # Security boundaries -Report vulnerabilities privately through GitHub's private vulnerability reporting for this repository when available. Do not post live credentials or exploit details in a public issue. No response-time or security-certification guarantee is made. +Use GitHub private vulnerability reporting for this repository. Do not publish live credentials. Automated proof is not security certification. -## What the credential protects +## Credentials and identity -Ogiri generates 32 random bytes using the JDK's `SecureRandom`, encoded as a canonical `og1_` token. PostgreSQL stores only the token's SHA-256 digest and non-secret metadata. This design relies on 256 bits of generated entropy; it is **not** a password-hashing scheme and must never be reused for human-chosen secrets. A read-only database leak does not directly disclose usable credentials. Database write access, process compromise and a stolen plaintext token remain outside that protection. +Ogiri generates 256-bit random `og1_` bearer tokens and stores only SHA-256 digests. This is suitable for generated high-entropy secrets, **not passwords**. A read-only database leak does not directly disclose usable tokens. Database writes, process compromise, stolen plaintext tokens and heap dumps remain security-sensitive. There is no server-side token pepper or HMAC key ring. -There is no keyed token-hash secret to distribute or rotate. That removes operational key-ring state, but also removes the separate-server-secret defence against an attacker who can rewrite credential digests. Protect database writes as authentication authority. A bearer token is sufficient to authenticate within the application's accepted identity context. +The full identity is `(realm, tenantId, stableSubjectId)`. The default Spring adapter supports one realm and immutable usernames; use `OgiriAccounts` for mutable logins, stable IDs or tenants. Both login mapping and request-time loading must validate the complete identity. Client names are untrusted display labels. Session UUIDs are management identifiers, never credentials. -## Application responsibilities +## HTTP and accounts -Authenticate before calling `issue`. Derive the complete owner from an authenticated principal before listing or revoking devices; never trust an arbitrary request-supplied owner. Validate every realm/tenant/account component in the Spring account loader. Protect management and recovery endpoints, enforce rate limits on sign-in, restrict CORS, configure CSRF for any ambient browser credentials, and use HTTPS. The example's JSON/custom-header sign-in exemption is valid only with its restricted-origin assumptions; it is not a universal CSRF policy. +The starter uses Spring Security's native bearer pipeline. It rejects duplicate Authorization fields and does not accept query/form bearer tokens. The default chain is supplied only when the application defines no chain. Existing applications opt into the helper per chain. It does not disable CSRF globally, replace authorization, create users or relax CORS. -Use stable account IDs. The adapter checks disabled, locked, account-expired and credentials-expired states at every request, after validating the token. It does not persist account-state changes or erase sessions during an authentication read. Re-enabling an account can therefore make its still-live sessions usable again unless the application explicitly revoked them. Password resets, compromise recovery, account deletion and permanent bans must revoke sessions in the application's identity workflow. Coordinate concurrent sign-in/recovery according to that workflow; Ogiri is not an atomic account-and-session transaction manager. +Built-in sign-in requires JSON plus `X-Requested-With: Ogiri` for its CSRF exemption. The header is not a secret: it prevents browser-simple cross-origin submission only when CORS is restricted. Use HTTPS and application/gateway rate limiting. Existing cookie, Basic and other ambient authentication still require the application's CSRF policy. Management endpoints require a typed Ogiri session principal and derive ownership from it. -No automatic rotation, refresh-token reuse detection, idle expiry, privilege-step-up or MFA is provided. A stolen token remains usable until its fixed expiry or explicit revocation, subject to current account checks. Choose a lifetime appropriate to the threat model; seven days is a configurable convenience default, not a universal security recommendation. Use a shorter lifetime and re-authentication, or an established identity provider, when stronger lifecycle controls are required. +Account status and authorities are loaded on each authenticated request after token validation. Re-enabling an account can restore unrevoked, unexpired sessions. Password reset, permanent bans, deletion and compromise recovery must revoke sessions and coordinate concurrent sign-in in the identity workflow. Ogiri cannot atomically update application account state that it does not own. Default seven-day lifetime and ten sessions are configurable convenience defaults, not universal threat-model recommendations. -## Storage and failure behaviour +No automatic refresh, rotation, idle timeout, replay-detection or MFA workflow is provided. Stolen tokens remain usable until expiry/revocation subject to account status. The application must choose an appropriate lifetime and re-authentication policy. -Only use a normal PostgreSQL connection pool, not a caller-bound transaction proxy. Reads must hit the authoritative primary, not a lagging replica. Already enlisted connections are rejected; each mutation commits independently before a credential or success result is returned. A connection lost during commit can leave the outcome unknown: the library throws and does not return a credential. Such an orphaned record can consume a slot until explicit revocation or expiry. No automatic retry pretends to resolve ambiguous commits. +## SQL, transactions and availability -Authentication does not write activity timestamps or cache a positive result. Revocation removes the row, so later primary reads cannot resurrect it from a cache. In-flight statements or requests begun before revocation may finish. Expiry is evaluated at statement start; it does not interrupt a statement already executing. Advisory-lock hash collisions can serialize unrelated accounts but do not merge identities: every account-management SQL statement separately compares all three identity columns. +Use packaged schema templates through your own migrations. MySQL requires InnoDB and exact non-padding identity collation. PostgreSQL and MySQL timestamps use database statement time represented as epoch milliseconds; expiry is checked at the start of authentication's statement. Already authorized requests are not retroactively cancelled. -Pool acquisition and network timeouts are host configuration. SQL statements use a five-second query timeout. Database and account-directory outages fail closed and remain distinguishable from invalid tokens; do not convert availability failures into fabricated successful principals. +Writes use Spring-managed independent transactions at READ_COMMITTED. Account admission and revoke-all take stable digest-keyed row locks. Lock digest collisions would add contention, not merge authorization, because SQL still checks all identity components. Lock rows must not be removed while writers can use them. They grow per identity with issued sessions; their retention avoids an unsafe lock-removal race. -## Credential handling +Read operations suspend outer JDBC transactions and query the primary. Do not supply transaction-aware or lagging-replica-routing data sources. An existing outer transaction needs additional connection capacity. Spring handles suspension/resumption, rollback and connection-state restoration; a connection loss during commit can still make the outcome unknown. In that case no credential is returned, but an orphaned row may occupy capacity. Do not blindly retry issuance. -Deliver `IssuedSession.token()` explicitly once and exclude it from logs, analytics, exception messages and default JSON serialization. `IssuedSession.toString()` is redacted, but its string accessor is deliberately sensitive. `Session` and the adapter's principal contain no token or digest. Java strings and Spring's native bearer authentication may retain credential bytes in memory; no complete memory-erasure guarantee is claimed. Treat heap dumps as secrets. +No authentication-positive cache exists. Storage/directory outages fail closed and remain distinct from invalid credentials. SQL timeout is five seconds; configure pool acquisition/socket timeouts, TLS and gateway limits separately. Cleanup skips locked expired rows and never determines whether an expired token is accepted. -Never send tokens in URLs or user-controlled client labels. Use secure client storage appropriate to the application. Browser HttpOnly session cookies require a different transport and CSRF arrangement; prefer Spring Session for that use case rather than embedding this bearer token in an ad hoc cookie wrapper. +## Secret handling and proof -## Verification limits +`IssuedSession` and login-request string rendering are redacted. The token accessor is deliberately sensitive; never log/serialize it, put it in URLs, or send it to analytics. JSON endpoints return only metadata with no-store responses. Spring/JDK strings can retain credentials in memory; no memory-erasure guarantee is claimed. -PostgreSQL behavioural tests, native HTTP consumer tests, static analysis and selected mutation probes are evidence, not proof of all interleavings, production performance or penetration-test coverage. Independent security review is still appropriate before production adoption. +Tests use disposable databases and destructive fixture setup. Functional tests, selected mutation probes, CodeQL, resolved-dependency scanning and local benchmarks cover different boundaries. No one signal proves absence of vulnerabilities, every possible race, or production capacity. Human review is still appropriate before production adoption. diff --git a/examples/spring-app/README.md b/examples/spring-app/README.md index 1b569c1..e8bc9da 100644 --- a/examples/spring-app/README.md +++ b/examples/spring-app/README.md @@ -1,15 +1,9 @@ -# Spring Boot consumer +# Independent Spring Boot consumer -This independent Maven application uses `ogiri-spring-security:0.1.0` from the local Maven repository after the root `mvn install`. It demonstrates Spring Boot 4.1.1 and the native Spring Security resource-server pipeline; it is not a library-owned authentication server. +The only authentication-related application bean is the demo `UserDetailsService`. The starter supplies pooling dependencies, session service, metadata, native security integration and JSON endpoints. In a real application, replace the in-memory directory with your existing account directory. -Apply the schema from the built core JAR to a disposable/local PostgreSQL database. Set `OGIRI_JDBC_URL`, `OGIRI_JDBC_USER`, `OGIRI_JDBC_PASSWORD` and a non-empty `OGIRI_DEMO_PASSWORD`, then run: +Install the library first. Provision `META-INF/ogiri/schema-postgresql.sql` or `schema-mysql.sql` from the installed core JAR through your own migration. Set `OGIRI_JDBC_URL`, `OGIRI_JDBC_USER`, `OGIRI_JDBC_PASSWORD`, and `OGIRI_DEMO_PASSWORD`, then run `mvn spring-boot:run` here. For MySQL add `-Pmysql`; the example's PostgreSQL runtime dependency can be removed in a MySQL application. -```sh -mvn -f examples/spring-app/pom.xml spring-boot:run -``` +Sign in with `POST /auth/sign-in`, JSON username `demo`, your password and a client label; include `X-Requested-With: Ogiri`. Use the returned Authorization header for `/me`, `/auth/session` and `/auth/sessions`. `/admin` demonstrates method-level role denial. -POST `/sessions` with JSON `username`, `password` and `client`, plus `X-Requested-With: ogiri-demo`. The only demo username is `demo`. Copy the response's `Authorization: Bearer ...` header to later requests. GET `/me` or `/sessions`, then DELETE `/sessions/{id}` to revoke an owned device. `/admin` requires an administrator role, which the demo user does not have. - -The application explicitly owns password authentication, credential delivery, origin/CSRF policy and authorization. It uses PBKDF2 for its temporary in-memory demo user. Replace the account directory and login workflow with your actual application. Do not enable permissive CORS around the JSON/custom-header sign-in exemption or reuse this demo as an account registration/recovery system. - -`ConsumerTest` runs against actual HTTP and PostgreSQL with a one-connection Hikari pool. It checks lifecycle, role denial, tenant-safe revocation, rejected credential transports and distinct account-directory failures. The example rejects duplicate Authorization headers before delegating parsing to Spring Security and permits only ERROR redispatches so an original 403/503 is not masked by error-page authorization. Its database is configured with the `OGIRI_TEST_JDBC_*` variables from the root contributing guide. +`ConsumerTest` verifies the default path with a one-connection pool. `HostChainTest` demonstrates two application-owned chains, a custom tenant/ID mapping, disabled endpoints, preserved CSRF and directory outages. Neither test assumes H2 represents a production database. diff --git a/examples/spring-app/pom.xml b/examples/spring-app/pom.xml index e9a76ea..65af1c9 100644 --- a/examples/spring-app/pom.xml +++ b/examples/spring-app/pom.xml @@ -4,12 +4,12 @@ com.quantipixels.ogiri.examplesspring-app0.1.0 170.1.0 - com.quantipixels.ogiriogiri-spring-security${ogiri.version} - org.springframework.bootspring-boot-starter-webmvc - org.springframework.bootspring-boot-starter-jdbc - org.springframework.bootspring-boot-starter-security-oauth2-resource-server + com.quantipixels.ogiriogiri-spring-boot-starter${ogiri.version} org.postgresqlpostgresqlruntime org.springframework.bootspring-boot-starter-testtest + mysql + com.mysqlmysql-connector-jruntime + org.springframework.bootspring-boot-maven-plugin diff --git a/examples/spring-app/src/main/java/example/ogiri/Application.java b/examples/spring-app/src/main/java/example/ogiri/Application.java index 99bc0ef..47b34ed 100644 --- a/examples/spring-app/src/main/java/example/ogiri/Application.java +++ b/examples/spring-app/src/main/java/example/ogiri/Application.java @@ -1,128 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 package example.ogiri; -import com.quantipixels.ogiri.*; -import com.quantipixels.ogiri.spring.OgiriOpaqueTokenIntrospector; -import java.util.List; -import java.util.UUID; -import javax.sql.DataSource; +import java.security.Principal; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; -import org.springframework.http.*; -import org.springframework.security.authentication.*; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.core.userdetails.*; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; -import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; import org.springframework.security.provisioning.InMemoryUserDetailsManager; -import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; -/** Runnable integration example. Demo users are not an account-registration system. */ @SpringBootApplication +@EnableMethodSecurity +@RestController public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } - @Bean PostgresSessions sessions(DataSource dataSource) { return new PostgresSessions(dataSource); } - @Bean PasswordEncoder passwords() { return Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(); } - - @Bean UserDetailsService accounts(PasswordEncoder encoder, @Value("${demo.password}") String password) { - if (password.isBlank()) throw new IllegalArgumentException("Set OGIRI_DEMO_PASSWORD"); + @Bean + UserDetailsService accounts(PasswordEncoder encoder, @Value("${demo.password}") String password) { return new InMemoryUserDetailsManager(User.withUsername("demo").password(encoder.encode(password)).roles("USER").build()); } - @Bean AuthenticationManager loginManager(UserDetailsService accounts, PasswordEncoder passwords) { - var provider = new DaoAuthenticationProvider(accounts); - provider.setPasswordEncoder(passwords); - return new ProviderManager(provider); - } - - @Bean OpaqueTokenIntrospector introspector(PostgresSessions sessions, UserDetailsService accounts) { - return new OgiriOpaqueTokenIntrospector(sessions, subject -> { - if (!subject.realm().equals("demo") || !subject.tenantId().isEmpty()) throw new UsernameNotFoundException("Unknown account"); - // This demo's immutable username is its account ID. Real mutable logins need stable IDs. - return accounts.loadUserByUsername(subject.subjectId()); - }); - } - - @Bean SecurityFilterChain security(HttpSecurity http, OpaqueTokenIntrospector introspector) throws Exception { - var nativeBearer = new org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver(); - return http - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(routes -> routes - .dispatcherTypeMatchers(jakarta.servlet.DispatcherType.ERROR).permitAll() - .requestMatchers(HttpMethod.POST, "/sessions").permitAll() - .requestMatchers("/admin").hasRole("ADMIN") - .anyRequest().authenticated()) - .oauth2ResourceServer(resource -> resource - .bearerTokenResolver(request -> { - var values = request.getHeaders(HttpHeaders.AUTHORIZATION); - if (values.hasMoreElements()) { - values.nextElement(); - if (values.hasMoreElements()) throw new org.springframework.security.oauth2.core.OAuth2AuthenticationException( - org.springframework.security.oauth2.server.resource.BearerTokenErrors.invalidRequest("Multiple Authorization headers")); - } - return nativeBearer.resolve(request); - }) - .opaqueToken(opaque -> opaque.introspector(introspector))) - // Only this JSON, custom-header sign-in is exempt. Do not enable permissive CORS. - // The native resource server separately handles CSRF for explicit Bearer credentials. - .csrf(csrf -> csrf.ignoringRequestMatchers(request -> - request.getMethod().equals("POST") && request.getServletPath().equals("/sessions") - && "ogiri-demo".equals(request.getHeader("X-Requested-With")) - && request.getContentType() != null - && request.getContentType().split(";", 2)[0].trim().equalsIgnoreCase("application/json"))) - .build(); - } - - @RestController - static class Endpoints { - private final PostgresSessions sessions; - private final AuthenticationManager authentication; - Endpoints(PostgresSessions sessions, AuthenticationManager authentication) { - this.sessions = sessions; this.authentication = authentication; - } - - record Login(String username, String password, String client) {} - - @PostMapping(value = "/sessions", consumes = MediaType.APPLICATION_JSON_VALUE) - ResponseEntity signIn(@RequestBody Login login) { - if (login.username() == null || login.username().isBlank() || login.username().length() > 255 - || login.password() == null || login.password().isBlank() || login.password().length() > 1024 - || login.client() == null || login.client().isBlank() || login.client().length() > 255 || login.client().indexOf(0) >= 0) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST); - final org.springframework.security.core.Authentication account; - try { account = authentication.authenticate(UsernamePasswordAuthenticationToken.unauthenticated(login.username(), login.password())); } - catch (AuthenticationServiceException unavailable) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE); } - catch (AuthenticationException invalid) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } - try { - var issued = sessions.issue(new Subject("demo", "", account.getName()), login.client()); - return ResponseEntity.status(HttpStatus.CREATED).cacheControl(CacheControl.noStore()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + issued.token()).body(issued.session()); - } catch (SessionLimitException full) { throw new ResponseStatusException(HttpStatus.CONFLICT); } - } - - @GetMapping("/me") java.util.Map current(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { return principal.getAttributes(); } - - @GetMapping("/sessions") List list(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { return sessions.list(owner(principal)); } - - @DeleteMapping("/sessions/{id}") ResponseEntity revoke(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal, @PathVariable UUID id) { - return sessions.revoke(owner(principal), id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); - } - - @GetMapping("/admin") String admin() { return "admin"; } - - private static Subject owner(OAuth2AuthenticatedPrincipal principal) { - return new Subject(principal.getAttribute("realm"), principal.getAttribute("tenant_id"), principal.getName()); - } - } + @GetMapping("/me") String me(Principal principal) { return principal.getName(); } + @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") String admin() { return "admin"; } } diff --git a/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java b/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java index 3d4a598..fc12918 100644 --- a/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java +++ b/examples/spring-app/src/test/java/example/ogiri/ConsumerTest.java @@ -20,23 +20,9 @@ "spring.datasource.hikari.maximum-pool-size=1", "demo.password=test-password" }) -@org.springframework.context.annotation.Import(ConsumerTest.OutageConfiguration.class) class ConsumerTest { - @org.springframework.boot.test.context.TestConfiguration(proxyBeanMethods = false) - static class OutageConfiguration { - @org.springframework.context.annotation.Bean - @org.springframework.context.annotation.Primary - org.springframework.security.core.userdetails.UserDetailsService faultableAccounts( - @org.springframework.beans.factory.annotation.Qualifier("accounts") org.springframework.security.core.userdetails.UserDetailsService delegate) { - return username -> { - if (username.equals("offline")) throw new org.springframework.security.authentication.InternalAuthenticationServiceException("Injected directory outage"); - return delegate.loadUserByUsername(username); - }; - } - } - @Value("${local.server.port}") private int port; - @Autowired private PostgresSessions sessions; + @Autowired private JdbcSessions sessions; @Autowired private DataSource dataSource; private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); @@ -51,47 +37,48 @@ org.springframework.security.core.userdetails.UserDetailsService faultableAccoun assertTrue(login.headers().firstValue("Cache-Control").orElse("").contains("no-store")); String bearer = login.headers().firstValue("Authorization").orElseThrow(); assertFalse(login.body().contains(bearer.substring(7)), "Response JSON must not contain the credential"); - UUID id = sessions.list(new Subject("demo", "", "demo")).get(0).id(); + UUID id = sessions.list(new Subject("users", "", "demo")).get(0).id(); assertEquals(200, request("GET", "/me", null, bearer).statusCode()); - assertEquals(200, request("GET", "/sessions", null, bearer).statusCode()); + assertEquals(200, request("GET", "/auth/sessions", null, bearer).statusCode()); assertEquals(403, request("GET", "/admin", null, bearer).statusCode()); - var foreign = sessions.issue(new Subject("demo", "another-tenant", "demo"), "foreign"); - assertEquals(404, request("DELETE", "/sessions/" + foreign.session().id(), null, bearer).statusCode()); + var foreign = sessions.issue(new Subject("users", "another-tenant", "demo"), "foreign"); + assertEquals(404, request("DELETE", "/auth/sessions/" + foreign.session().id(), null, bearer).statusCode()); assertTrue(sessions.authenticate(foreign.token()).isPresent()); - assertEquals(204, request("DELETE", "/sessions/" + id, null, bearer).statusCode()); + assertEquals(204, request("DELETE", "/auth/sessions/" + id, null, bearer).statusCode()); assertEquals(401, request("GET", "/me", null, bearer).statusCode()); } @Test void invalidLoginSimpleCrossSitePostsAndAmbiguousBearerTransportAreRejected() throws Exception { assertEquals(401, login("wrong-password").statusCode()); - assertEquals(403, request("POST", "/sessions", "text/plain", null).statusCode()); - assertEquals(403, request("POST", "/sessions", "application/json", null).statusCode()); + assertEquals(403, request("POST", "/auth/sign-in", "text/plain", null).statusCode()); + assertEquals(403, request("POST", "/auth/sign-in", "application/json", null).statusCode()); var invalid = request("GET", "/me", null, "Bearer og1_" + "A".repeat(43)); assertEquals(401, invalid.statusCode()); - var issued = sessions.issue(new Subject("demo", "", "demo"), "phone"); + var issued = sessions.issue(new Subject("users", "", "demo"), "phone"); assertEquals(401, request("GET", "/me?access_token=" + issued.token(), null, null).statusCode()); var duplicate = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/me")) .header("Authorization", "Bearer " + issued.token()).header("Authorization", "Bearer " + issued.token()).GET().build(); assertEquals(400, HTTP.send(duplicate, HttpResponse.BodyHandlers.ofString()).statusCode()); - assertEquals(1, sessions.list(new Subject("demo", "", "demo")).size()); + assertEquals(1, sessions.list(new Subject("users", "", "demo")).size()); } - @Test void accountDirectoryOutagesRemainServerFailuresRatherThanInvalidCredentials() throws Exception { - var login = login("offline", "irrelevant"); - assertEquals(503, login.statusCode()); - assertFalse(login.body().contains("Injected directory outage")); - var token = sessions.issue(new Subject("demo", "", "offline"), "test fault").token(); - var request = request("GET", "/me", null, "Bearer " + token); - assertEquals(500, request.statusCode()); - assertFalse(request.body().contains("Injected directory outage")); + @Test void signOutAndRevokeAllUseOnlyTheAuthenticatedOwner() throws Exception { + var owner = new Subject("users", "", "demo"); + var first = sessions.issue(owner, "one"); + var second = sessions.issue(owner, "two"); + assertEquals(204, request("DELETE", "/auth/sign-out", null, "Bearer " + first.token()).statusCode()); + assertTrue(sessions.authenticate(first.token()).isEmpty()); + assertTrue(sessions.authenticate(second.token()).isPresent()); + assertEquals(204, request("DELETE", "/auth/sessions", null, "Bearer " + second.token()).statusCode()); + assertTrue(sessions.list(owner).isEmpty()); } private HttpResponse login(String password) throws Exception { return login("demo", password); } private HttpResponse login(String username, String password) throws Exception { - var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/sessions")) + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/auth/sign-in")) .timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json") - .header("X-Requested-With", "ogiri-demo") + .header("X-Requested-With", "Ogiri") .POST(HttpRequest.BodyPublishers.ofString("{\"username\":\"" + username + "\",\"password\":\"" + password + "\",\"client\":\"browser\"}")).build(); return HTTP.send(request, HttpResponse.BodyHandlers.ofString()); } diff --git a/examples/spring-app/src/test/java/example/ogiri/HostChainTest.java b/examples/spring-app/src/test/java/example/ogiri/HostChainTest.java new file mode 100644 index 0000000..d4343c8 --- /dev/null +++ b/examples/spring-app/src/test/java/example/ogiri/HostChainTest.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +package example.ogiri; + +import static org.junit.jupiter.api.Assertions.*; +import com.quantipixels.ogiri.*; +import com.quantipixels.ogiri.spring.*; +import java.net.URI; +import java.net.http.*; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.*; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.*; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.*; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.*; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.*; + +@SpringBootTest(classes = {Application.class, HostChainTest.Host.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.datasource.url=${OGIRI_TEST_JDBC_URL}", "spring.datasource.username=${OGIRI_TEST_JDBC_USER}", + "spring.datasource.password=${OGIRI_TEST_JDBC_PASSWORD}", "demo.password=test-password", + "ogiri.endpoints-enabled=false" +}) +class HostChainTest { + @Autowired JdbcSessions sessions; + @Autowired org.springframework.context.ApplicationContext context; + @Value("${local.server.port}") int port; + private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + static final AtomicBoolean offline = new AtomicBoolean(); + + @TestConfiguration(proxyBeanMethods = false) + static class Host { + @Bean OgiriAccounts scopedAccounts() { + return new OgiriAccounts() { + public Subject subject(Authentication authentication) { return new Subject("accounts", "tenant", "42"); } + public UserDetails load(Subject subject) { + if (offline.get()) throw new IllegalStateException("private directory failure"); + if (!subject.equals(new Subject("accounts", "tenant", "42"))) throw new UsernameNotFoundException("foreign"); + return User.withUsername("mutable-name").password("unused").roles("USER").build(); + } + }; + } + @Bean @Order(1) SecurityFilterChain publicChain(HttpSecurity http) throws Exception { + return http.securityMatcher("/public/**").authorizeHttpRequests(routes -> routes.anyRequest().permitAll()).build(); + } + @Bean @Order(2) SecurityFilterChain applicationChain(HttpSecurity http, OgiriSecurity ogiri) throws Exception { + ogiri.configure(http); + return http.authorizeHttpRequests(routes -> routes.dispatcherTypeMatchers(jakarta.servlet.DispatcherType.ERROR).permitAll() + .requestMatchers("/auth/**").denyAll().anyRequest().authenticated()).build(); + } + } + + @Test void customChainsAndFullIdentityMappingRemainAuthoritative() throws Exception { + assertEquals(2, context.getBeansOfType(SecurityFilterChain.class).size()); + assertEquals(0, context.getBeansOfType(OgiriEndpoints.class).size()); + var token = sessions.issue(new Subject("accounts", "tenant", "42"), "host").token(); + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/me")) + .header("Authorization", "Bearer " + token).GET().build(); + var response = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("42", response.body()); + // Host's unrelated public write still requires CSRF. + var post = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/public/write")) + .POST(HttpRequest.BodyPublishers.noBody()).build(); + assertEquals(403, HTTP.send(post, HttpResponse.BodyHandlers.ofString()).statusCode()); + var disabled = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/auth/sessions")) + .header("Authorization", "Bearer " + token).GET().build(); + assertEquals(403, HTTP.send(disabled, HttpResponse.BodyHandlers.ofString()).statusCode()); + try { + offline.set(true); + var outage = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(500, outage.statusCode()); + assertFalse(outage.body().contains("private directory failure")); + } finally { offline.set(false); sessions.revokeAll(new Subject("accounts", "tenant", "42")); } + } +} diff --git a/ogiri-spring-boot-starter/pom.xml b/ogiri-spring-boot-starter/pom.xml new file mode 100644 index 0000000..e5fae71 --- /dev/null +++ b/ogiri-spring-boot-starter/pom.xml @@ -0,0 +1,21 @@ + + 4.0.0 + com.quantipixels.ogiriogiri-parent0.1.0 + ogiri-spring-boot-starter + Ogiri Spring Boot starter + + com.quantipixels.ogiriogiri${project.version} + org.springframework.bootspring-boot-starter-webmvc + org.springframework.bootspring-boot-starter-jdbc + org.springframework.bootspring-boot-starter-security-oauth2-resource-server + org.springframework.bootspring-boot-starter-testtest + org.postgresqlpostgresqltest + com.mysqlmysql-connector-jtest + + org.apache.maven.pluginsmaven-compiler-plugin + + org.springframework.bootspring-boot-configuration-processor${spring-boot.version} + org.springframework.bootspring-boot-autoconfigure-processor${spring-boot.version} + + + diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAccounts.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAccounts.java new file mode 100644 index 0000000..049e2d7 --- /dev/null +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAccounts.java @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri.spring; + + +import com.quantipixels.ogiri.Subject; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; + +/** Application-owned mapping between login authentication and stable, fully scoped identity. */ +public interface OgiriAccounts { + Subject subject(Authentication authentication); + UserDetails load(Subject subject); +} diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java new file mode 100644 index 0000000..e4519ad --- /dev/null +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri.spring; + + +import com.quantipixels.ogiri.JdbcSessions; +import com.quantipixels.ogiri.SessionPolicy; +import com.quantipixels.ogiri.Subject; +import javax.sql.DataSource; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.*; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +/** Boot owns discovery, configuration binding, pooling and native Security integration. */ +@AutoConfiguration( + afterName = "org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", + beforeName = {"org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration"}) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(prefix = "ogiri", name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(OgiriProperties.class) +@EnableWebSecurity +public final class OgiriAutoConfiguration { + @Bean @ConditionalOnMissingBean + SessionPolicy ogiriPolicy(OgiriProperties properties) { + return new SessionPolicy(properties.lifetime(), properties.maximumSessions()); + } + + @Bean @ConditionalOnMissingBean + JdbcSessions ogiriSessions(DataSource source, SessionPolicy policy) { return new JdbcSessions(source, policy); } + + @Bean @ConditionalOnMissingBean + PasswordEncoder ogiriPasswordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } + + @Bean @ConditionalOnMissingBean + OgiriAccounts ogiriAccounts(UserDetailsService users, OgiriProperties properties) { + return new OgiriAccounts() { + public Subject subject(Authentication authentication) { + return new Subject(properties.realm(), "", authentication.getName()); + } + public UserDetails load(Subject subject) { + if (!subject.realm().equals(properties.realm()) || !subject.tenantId().isEmpty()) + throw new UsernameNotFoundException("Account outside default identity namespace"); + return users.loadUserByUsername(subject.subjectId()); + } + }; + } + + @Bean @ConditionalOnMissingBean + OgiriOpaqueTokenIntrospector ogiriIntrospector(JdbcSessions sessions, OgiriAccounts accounts) { + return new OgiriOpaqueTokenIntrospector(sessions, accounts::load); + } + + @Bean @ConditionalOnMissingBean + OgiriSecurity ogiriSecurity(OgiriOpaqueTokenIntrospector introspector, OgiriProperties properties) { + return new OgiriSecurity(introspector, properties); + } + + @Bean @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "ogiri", name = "endpoints-enabled", havingValue = "true", matchIfMissing = true) + OgiriEndpoints ogiriEndpoints(JdbcSessions sessions, OgiriAccounts accounts, + org.springframework.beans.factory.ObjectProvider managers, + AuthenticationConfiguration configuration) throws Exception { + var manager = managers.getIfAvailable(); + if (manager == null) manager = configuration.getAuthenticationManager(); + if (manager == null) throw new IllegalStateException("Provide a UserDetailsService, AuthenticationProvider or AuthenticationManager"); + return new OgiriEndpoints(sessions, accounts, manager); + } + + @Bean @ConditionalOnMissingBean(SecurityFilterChain.class) + SecurityFilterChain ogiriSecurityFilterChain(HttpSecurity http, OgiriSecurity security) throws Exception { + return security.defaults(http); + } +} diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriEndpoints.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriEndpoints.java new file mode 100644 index 0000000..aab6703 --- /dev/null +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriEndpoints.java @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri.spring; + + +import com.quantipixels.ogiri.*; +import java.util.List; +import java.util.UUID; +import org.springframework.http.*; +import org.springframework.security.authentication.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +/** Optional JSON endpoints. Management requires an authenticated Ogiri session, not an arbitrary user-supplied owner. */ +@RestController +@RequestMapping("${ogiri.base-path:/auth}") +public final class OgiriEndpoints { + private final JdbcSessions sessions; + private final OgiriAccounts accounts; + private final AuthenticationManager authentication; + + public OgiriEndpoints(JdbcSessions sessions, OgiriAccounts accounts, AuthenticationManager authentication) { + this.sessions = sessions; this.accounts = accounts; this.authentication = authentication; + } + + public record Login(String username, String password, String client) { + @Override public String toString() { return "Login[redacted]"; } + } + + @PostMapping(value = "/sign-in", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity signIn(@RequestBody Login login) { + if (!valid(login.username(), 255) || !valid(login.password(), 1024) || !valid(login.client(), 255)) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + var credentials = UsernamePasswordAuthenticationToken.unauthenticated(login.username(), login.password()); + try { + Authentication account = authentication.authenticate(credentials); + if (account == null || !account.isAuthenticated()) throw new BadCredentialsException("Authentication required"); + var issued = sessions.issue(accounts.subject(account), login.client()); + return ResponseEntity.status(HttpStatus.CREATED).cacheControl(CacheControl.noStore()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + issued.token()).body(issued.session()); + } finally { credentials.eraseCredentials(); } + } + + @GetMapping("/session") + public ResponseEntity current(Authentication authentication) { return noStore(currentSession(authentication)); } + + @GetMapping("/sessions") + public ResponseEntity> list(Authentication authentication) { + return noStore(sessions.list(currentSession(authentication).subject())); + } + + @DeleteMapping("/sessions/{id}") + public ResponseEntity revoke(Authentication authentication, @PathVariable UUID id) { + boolean removed = sessions.revoke(currentSession(authentication).subject(), id); + return ResponseEntity.status(removed ? HttpStatus.NO_CONTENT : HttpStatus.NOT_FOUND).cacheControl(CacheControl.noStore()).build(); + } + + @DeleteMapping("/sign-out") + public ResponseEntity signOut(Authentication authentication) { + Session current = currentSession(authentication); + sessions.revoke(current.subject(), current.id()); + return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build(); + } + + @DeleteMapping("/sessions") + public ResponseEntity revokeAll(Authentication authentication) { + sessions.revokeAll(currentSession(authentication).subject()); + return ResponseEntity.noContent().cacheControl(CacheControl.noStore()).build(); + } + + @ExceptionHandler({AuthenticationException.class, SessionStoreException.class, SessionLimitException.class}) + public ResponseEntity failure(RuntimeException failure) { + HttpStatus status = failure instanceof SessionLimitException ? HttpStatus.CONFLICT + : failure instanceof AuthenticationServiceException || failure instanceof SessionStoreException + ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.UNAUTHORIZED; + return ResponseEntity.status(status).cacheControl(CacheControl.noStore()) + .body(ProblemDetail.forStatus(status)); + } + + private static Session currentSession(Authentication authentication) { + if (authentication != null && authentication.isAuthenticated() + && authentication.getPrincipal() instanceof OAuth2AuthenticatedPrincipal principal + && principal.getAttribute("ogiri_session") instanceof Session session) return session; + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + private static ResponseEntity noStore(T value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value); } + private static boolean valid(String value, int maximum) { + return value != null && !value.isBlank() && value.length() <= maximum && value.indexOf(0) < 0; + } +} diff --git a/ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java similarity index 90% rename from ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java rename to ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java index ae10d26..e9b19a6 100644 --- a/ogiri-spring-security/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospector.java @@ -2,7 +2,7 @@ // Copyright (c) 2026 Quanti Pixels package com.quantipixels.ogiri.spring; -import com.quantipixels.ogiri.PostgresSessions; +import com.quantipixels.ogiri.JdbcSessions; import com.quantipixels.ogiri.Session; import com.quantipixels.ogiri.SessionStoreException; import com.quantipixels.ogiri.Subject; @@ -26,11 +26,11 @@ * The returned principal contains metadata, never the credential or stored digest. */ public final class OgiriOpaqueTokenIntrospector implements OpaqueTokenIntrospector { - private final PostgresSessions sessions; + private final JdbcSessions sessions; private final Function accounts; private final AccountStatusUserDetailsChecker status = new AccountStatusUserDetailsChecker(); - public OgiriOpaqueTokenIntrospector(PostgresSessions sessions, Function accounts) { + public OgiriOpaqueTokenIntrospector(JdbcSessions sessions, Function accounts) { this.sessions = Objects.requireNonNull(sessions, "sessions"); this.accounts = Objects.requireNonNull(accounts, "accounts"); } @@ -54,6 +54,6 @@ public OgiriOpaqueTokenIntrospector(PostgresSessions sessions, Function resource + .bearerTokenResolver(request -> { + var values = request.getHeaders(HttpHeaders.AUTHORIZATION); + if (values.hasMoreElements()) { + values.nextElement(); + if (values.hasMoreElements()) throw new OAuth2AuthenticationException( + BearerTokenErrors.invalidRequest("Multiple Authorization headers")); + } + return nativeResolver.resolve(request); + }) + .opaqueToken(opaque -> opaque.introspector(introspector))); + } + + /** Only non-simple JSON sign-in; use for explicit CSRF exemptions in an application-owned chain. */ + public RequestMatcher signInRequest() { + return request -> properties.endpointsEnabled() + && request.getMethod().equals("POST") + && request.getServletPath().equals(properties.basePath() + "/sign-in") + && "Ogiri".equals(request.getHeader("X-Requested-With")) + && request.getContentType() != null + && request.getContentType().split(";", 2)[0].trim().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE); + } + + SecurityFilterChain defaults(HttpSecurity http) throws Exception { + configure(http); + return http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .requestCache(cache -> cache.disable()) + .authorizeHttpRequests(routes -> { + routes.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll(); + if (properties.endpointsEnabled()) routes.requestMatchers(signInRequest()).permitAll(); + routes.anyRequest().authenticated(); + }) + .csrf(csrf -> csrf.ignoringRequestMatchers(signInRequest())) + .build(); + } +} diff --git a/ogiri-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ogiri-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..efe1095 --- /dev/null +++ b/ogiri-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.quantipixels.ogiri.spring.OgiriAutoConfiguration diff --git a/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java new file mode 100644 index 0000000..c65af22 --- /dev/null +++ b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.quantipixels.ogiri.spring; + +import static org.assertj.core.api.Assertions.assertThat; +import com.quantipixels.ogiri.*; +import java.time.Duration; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.authentication.*; +import org.springframework.security.crypto.password.*; + +class OgiriAutoConfigurationTest { + private final WebApplicationContextRunner runner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OgiriAutoConfiguration.class)) + .withBean(DataSource.class, () -> new DriverManagerDataSource(System.getenv("OGIRI_TEST_JDBC_URL"), + System.getenv("OGIRI_TEST_JDBC_USER"), System.getenv("OGIRI_TEST_JDBC_PASSWORD"))) + .withBean(UserDetailsService.class, () -> new InMemoryUserDetailsManager( + User.withUsername("test").password("{noop}test").roles("USER").build())); + + @Test void disabledStarterAddsNoSecurityOrStorageBeans() { + runner.withPropertyValues("ogiri.enabled=false").run(context -> { + assertThat(context).hasNotFailed().doesNotHaveBean(JdbcSessions.class).doesNotHaveBean(OgiriSecurity.class); + }); + } + + @Test void boundPolicyAndCustomAuthenticationBeansWin() { + var manager = (org.springframework.security.authentication.AuthenticationManager) authentication -> authentication; + var encoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + runner.withPropertyValues("ogiri.lifetime=12h", "ogiri.maximum-sessions=3", "ogiri.base-path=/api/login") + .withBean(org.springframework.security.authentication.AuthenticationManager.class, () -> manager) + .withBean(PasswordEncoder.class, () -> encoder).run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(SessionPolicy.class)).isEqualTo(new SessionPolicy(Duration.ofHours(12), 3)); + assertThat(context.getBean(PasswordEncoder.class)).isSameAs(encoder); + assertThat(context.getBean(org.springframework.security.authentication.AuthenticationManager.class)).isSameAs(manager); + }); + } + + @Test void invalidEndpointPathsFailAtBindingRatherThanOpenUnintendedRoutes() { + runner.withPropertyValues("ogiri.base-path=/auth/**").run(context -> assertThat(context).hasFailed()); + } +} diff --git a/ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java similarity index 84% rename from ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java rename to ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java index 8f2465f..b8ccfb0 100644 --- a/ogiri-spring-security/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java +++ b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java @@ -7,32 +7,32 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.*; -import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.core.io.ClassPathResource; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException; import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionException; class OgiriOpaqueTokenIntrospectorTest { - private static PGSimpleDataSource dataSource; - private PostgresSessions sessions; + private static DriverManagerDataSource dataSource; + private JdbcSessions sessions; private static final Subject OWNER = new Subject("users", "tenant-a", "stable-42"); @BeforeAll static void database() throws Exception { - dataSource = new PGSimpleDataSource(); - dataSource.setURL(java.util.Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL"), "Disposable PostgreSQL required")); - dataSource.setUser(System.getenv("OGIRI_TEST_JDBC_USER")); - dataSource.setPassword(System.getenv("OGIRI_TEST_JDBC_PASSWORD")); - try (var connection = dataSource.getConnection(); var statement = connection.createStatement(); - var schema = PostgresSessions.class.getResourceAsStream("/META-INF/ogiri/schema-postgresql.sql")) { + dataSource = new DriverManagerDataSource(java.util.Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL")), + System.getenv("OGIRI_TEST_JDBC_USER"), System.getenv("OGIRI_TEST_JDBC_PASSWORD")); + try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { statement.execute("DROP TABLE IF EXISTS ogiri_sessions"); - statement.execute(new String(java.util.Objects.requireNonNull(schema).readAllBytes(), StandardCharsets.UTF_8)); + statement.execute("DROP TABLE IF EXISTS ogiri_subject_locks"); } + new ResourceDatabasePopulator(new ClassPathResource("META-INF/ogiri/schema-" + System.getenv("OGIRI_TEST_DATABASE") + ".sql")).execute(dataSource); } @BeforeEach void reset() throws Exception { try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { statement.execute("TRUNCATE ogiri_sessions"); } - sessions = new PostgresSessions(dataSource); + sessions = new JdbcSessions(dataSource); } @Test void principalCarriesStableScopedIdentityAndCurrentAuthoritiesButNoSecret() { diff --git a/ogiri-spring-security/pom.xml b/ogiri-spring-security/pom.xml deleted file mode 100644 index 7b2e5ad..0000000 --- a/ogiri-spring-security/pom.xml +++ /dev/null @@ -1,12 +0,0 @@ - - 4.0.0 - com.quantipixels.ogiriogiri-parent0.1.0 - ogiri-spring-security - Ogiri Spring Security adapter - - com.quantipixels.ogiriogiri${project.version} - org.springframework.securityspring-security-oauth2-resource-server - org.junit.jupiterjunit-jupitertest - org.postgresqlpostgresqltest - - diff --git a/ogiri/pom.xml b/ogiri/pom.xml index c31b43a..6b663fe 100644 --- a/ogiri/pom.xml +++ b/ogiri/pom.xml @@ -2,8 +2,11 @@ 4.0.0 com.quantipixels.ogiriogiri-parent0.1.0 ogiri - Ogiri PostgreSQL sessions + Ogiri Spring JDBC sessions + org.springframeworkspring-jdbc + com.mysqlmysql-connector-jtest + com.zaxxerHikariCPtest org.junit.jupiterjunit-jupitertest org.postgresqlpostgresqltest diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Database.java b/ogiri/src/main/java/com/quantipixels/ogiri/Database.java new file mode 100644 index 0000000..da3e012 --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Database.java @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + + +import java.sql.SQLException; +import javax.sql.DataSource; + +/** SQL variation stays internal; unsupported vendors fail at startup rather than silently degrading. */ +enum Database { + POSTGRESQL("FLOOR(EXTRACT(EPOCH FROM statement_timestamp()) * 1000)", + "INSERT INTO ogiri_subject_locks (lock_key) VALUES (?) ON CONFLICT DO NOTHING"), + MYSQL("FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000)", + "INSERT INTO ogiri_subject_locks (lock_key) VALUES (?) ON DUPLICATE KEY UPDATE lock_key = lock_key"); + + final String now; + final String insertLock; + Database(String now, String insertLock) { this.now = now; this.insertLock = insertLock; } + + static Database detect(DataSource source) { + try (var connection = source.getConnection()) { + var metadata = connection.getMetaData(); + return switch (metadata.getDatabaseProductName()) { + case "PostgreSQL" -> POSTGRESQL; + case "MySQL" -> { + if (metadata.getDatabaseMajorVersion() < 8) throw new IllegalArgumentException("Ogiri requires MySQL 8 or newer"); + yield MYSQL; + } + default -> throw new IllegalArgumentException("Ogiri supports PostgreSQL and MySQL, not " + metadata.getDatabaseProductName()); + }; + } catch (SQLException failure) { throw new SessionStoreException(failure); } + } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java b/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java new file mode 100644 index 0000000..fbb3a54 --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import javax.sql.DataSource; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.support.JdbcTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Thread-safe PostgreSQL/MySQL session lifecycle using Spring JDBC and transaction management. + * Uses the supplied native DataSource, never a transaction-aware or replica-routing proxy. + * Mutations commit independently; reads suspend a caller transaction to avoid stale snapshots. + * SQL has a five-second timeout. Pool acquisition and network timeouts are host configuration. + */ +public final class JdbcSessions { + private static final String COLUMNS = "id, realm, tenant_id, subject_id, client, created_at, expires_at"; + private static final String OWNER = "realm = ? AND tenant_id = ? AND subject_id = ?"; + private final JdbcTemplate jdbc; + private final NamedParameterJdbcTemplate named; + private final TransactionTemplate mutations; + private final TransactionTemplate reads; + private final Database database; + private final SessionPolicy policy; + + public JdbcSessions(DataSource source) { this(source, SessionPolicy.defaults()); } + + public JdbcSessions(DataSource source, SessionPolicy policy) { + this.policy = Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(source, "source"); + if (source instanceof org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy) + throw new IllegalArgumentException("Supply the underlying DataSource, not TransactionAwareDataSourceProxy"); + this.database = Database.detect(source); + this.jdbc = new JdbcTemplate(source); + this.jdbc.setQueryTimeout(5); + this.named = new NamedParameterJdbcTemplate(jdbc); + var manager = new JdbcTransactionManager(source); + manager.setRollbackOnCommitFailure(true); + this.mutations = new TransactionTemplate(manager); + mutations.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + mutations.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + mutations.setTimeout(10); + this.reads = new TransactionTemplate(manager); + reads.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); + } + + /** Issue only after the caller has authenticated and authorized the full subject. */ + public IssuedSession issue(Subject subject, String client) { + Objects.requireNonNull(subject, "subject"); + if (client == null || client.isBlank() || client.length() > 255 || client.indexOf(0) >= 0) + throw new IllegalArgumentException("client must contain 1 to 255 characters"); + String token = Tokens.generate(); + return write(() -> { + lock(subject); + // Read database time after waiting for admission, not at transaction start. + long now = jdbc.queryForObject("SELECT " + database.now, Long.class); + int count = jdbc.queryForObject("SELECT count(*) FROM ogiri_sessions WHERE " + OWNER + " AND expires_at > ?", + Integer.class, subject.realm(), subject.tenantId(), subject.subjectId(), now); + if (count >= policy.maximumSessions()) throw new SessionLimitException(); + var session = new Session(UUID.randomUUID(), subject, client, Instant.ofEpochMilli(now), + Instant.ofEpochMilli(Math.addExact(now, policy.lifetime().toMillis()))); + jdbc.update("INSERT INTO ogiri_sessions (" + COLUMNS + ", token_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + session.id().toString(), subject.realm(), subject.tenantId(), subject.subjectId(), client, + now, session.expiresAt().toEpochMilli(), Tokens.digest(token)); + return new IssuedSession(session, token); + }); + } + + /** One authoritative indexed read, with no activity writes or positive-result cache. */ + public Optional authenticate(String token) { + byte[] digest = Tokens.digest(token); + if (digest == null) return Optional.empty(); + return read(() -> jdbc.query("SELECT " + COLUMNS + " FROM ogiri_sessions WHERE token_hash = ? AND expires_at > " + database.now, + JdbcSessions::row, digest).stream().findFirst()); + } + + /** List live session metadata for an application-authorized complete subject. */ + public List list(Subject subject) { + Objects.requireNonNull(subject, "subject"); + return read(() -> List.copyOf(jdbc.query("SELECT " + COLUMNS + " FROM ogiri_sessions WHERE " + OWNER + + " AND expires_at > " + database.now + " ORDER BY created_at, id", JdbcSessions::row, + subject.realm(), subject.tenantId(), subject.subjectId()))); + } + + /** Delete one owned session; foreign or absent identifiers return false. */ + public boolean revoke(Subject subject, UUID id) { + Objects.requireNonNull(subject, "subject"); Objects.requireNonNull(id, "id"); + return write(() -> jdbc.update("DELETE FROM ogiri_sessions WHERE " + OWNER + " AND id = ?", + subject.realm(), subject.tenantId(), subject.subjectId(), id.toString()) == 1); + } + + /** Serialize with issuance and revoke existing sessions; this does not ban future login. */ + public int revokeAll(Subject subject) { + Objects.requireNonNull(subject, "subject"); + return write(() -> { + lock(subject); + return jdbc.update("DELETE FROM ogiri_sessions WHERE " + OWNER, + subject.realm(), subject.tenantId(), subject.subjectId()); + }); + } + + /** Bounded cleanup, safe with concurrent workers. Expiry enforcement never waits for cleanup. */ + public int cleanup(int batchSize) { + if (batchSize < 1 || batchSize > 10_000) throw new IllegalArgumentException("batchSize must be between 1 and 10000"); + return write(() -> { + var ids = jdbc.queryForList("SELECT id FROM ogiri_sessions WHERE expires_at <= " + database.now + + " ORDER BY expires_at, id LIMIT ? FOR UPDATE SKIP LOCKED", String.class, batchSize); + return ids.isEmpty() ? 0 : named.update("DELETE FROM ogiri_sessions WHERE id IN (:ids)", Map.of("ids", ids)); + }); + } + + private void lock(Subject subject) { + byte[] key = Tokens.lockKey(subject); + jdbc.update(database.insertLock, key); + jdbc.queryForObject("SELECT lock_key FROM ogiri_subject_locks WHERE lock_key = ? FOR UPDATE", byte[].class, key); + } + + private T write(Supplier operation) { return execute(mutations, operation); } + private T read(Supplier operation) { return execute(reads, operation); } + private T execute(TransactionTemplate template, Supplier operation) { + try { return template.execute(status -> operation.get()); } + catch (DataAccessException | TransactionException failure) { throw new SessionStoreException(failure); } + } + + private static Session row(ResultSet rows, int index) throws SQLException { + return new Session(UUID.fromString(rows.getString("id")), + new Subject(rows.getString("realm"), rows.getString("tenant_id"), rows.getString("subject_id")), + rows.getString("client"), Instant.ofEpochMilli(rows.getLong("created_at")), + Instant.ofEpochMilli(rows.getLong("expires_at"))); + } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java b/ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java deleted file mode 100644 index d57643e..0000000 --- a/ogiri/src/main/java/com/quantipixels/ogiri/PostgresSessions.java +++ /dev/null @@ -1,189 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2026 Quanti Pixels -package com.quantipixels.ogiri; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import javax.sql.DataSource; - -/** - * Thread-safe PostgreSQL opaque-session lifecycle. Supply an ordinary pool, not a transaction-bound - * proxy. Mutations use independent committed transactions. Authentication is a read-only lookup. - * Pool acquisition/network timeouts remain the application's responsibility; each SQL statement - * has a five-second query timeout. Schema creation and scheduling are explicitly application-owned. - */ -public final class PostgresSessions { - private static final String COLUMNS = "id, realm, tenant_id, subject_id, client, created_at, expires_at"; - private static final String OWNER = "realm = ? AND tenant_id = ? AND subject_id = ?"; - private final DataSource dataSource; - private final SessionPolicy policy; - - public PostgresSessions(DataSource dataSource) { this(dataSource, SessionPolicy.defaults()); } - - public PostgresSessions(DataSource dataSource, SessionPolicy policy) { - this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); - this.policy = Objects.requireNonNull(policy, "policy"); - } - - /** Issue only after the application authenticates and authorizes the complete Subject. */ - public IssuedSession issue(Subject subject, String client) { - Objects.requireNonNull(subject, "subject"); - if (client == null || client.isBlank() || client.length() > 255 || client.indexOf(0) >= 0) - throw new IllegalArgumentException("client must contain 1 to 255 characters"); - String token = Tokens.generate(); - return write(connection -> { - lock(connection, subject); - Instant now; - try (PreparedStatement statement = prepare(connection, "SELECT statement_timestamp()")) { - try (ResultSet rows = statement.executeQuery()) { rows.next(); now = rows.getObject(1, OffsetDateTime.class).toInstant(); } - } - try (PreparedStatement statement = prepare(connection, - "SELECT count(*) FROM ogiri_sessions WHERE " + OWNER + " AND expires_at > ?")) { - owner(statement, subject); instant(statement, 4, now); - try (ResultSet rows = statement.executeQuery()) { - rows.next(); - if (rows.getInt(1) >= policy.maximumSessions()) throw new SessionLimitException(); - } - } - Session session = new Session(UUID.randomUUID(), subject, client, now, now.plusMillis(policy.lifetime().toMillis())); - try (PreparedStatement statement = prepare(connection, - "INSERT INTO ogiri_sessions (" + COLUMNS + ", token_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { - statement.setObject(1, session.id()); - statement.setString(2, subject.realm()); statement.setString(3, subject.tenantId()); statement.setString(4, subject.subjectId()); - statement.setString(5, client); instant(statement, 6, now); instant(statement, 7, session.expiresAt()); - statement.setBytes(8, Tokens.digest(token)); statement.executeUpdate(); - } - return new IssuedSession(session, token); - }); - } - - /** Find a valid credential at the database statement's start; malformed, expired and revoked tokens return empty. */ - public Optional authenticate(String token) { - byte[] digest = Tokens.digest(token); - if (digest == null) return Optional.empty(); - try (Connection connection = connection(); - PreparedStatement statement = prepare(connection, - "SELECT " + COLUMNS + " FROM ogiri_sessions WHERE token_hash = ? AND expires_at > statement_timestamp()")) { - statement.setBytes(1, digest); - try (ResultSet rows = statement.executeQuery()) { return rows.next() ? Optional.of(session(rows)) : Optional.empty(); } - } catch (SQLException failure) { throw new SessionStoreException(failure); } - } - - /** List live metadata for an application-authorized owner, in creation order. Never use a request-supplied owner unchecked. */ - public List list(Subject subject) { - Objects.requireNonNull(subject, "subject"); - try (Connection connection = connection(); - PreparedStatement statement = prepare(connection, - "SELECT " + COLUMNS + " FROM ogiri_sessions WHERE " + OWNER + " AND expires_at > statement_timestamp() ORDER BY created_at, id")) { - owner(statement, subject); - try (ResultSet rows = statement.executeQuery()) { - List sessions = new ArrayList<>(); - while (rows.next()) sessions.add(session(rows)); - return List.copyOf(sessions); - } - } catch (SQLException failure) { throw new SessionStoreException(failure); } - } - - /** Revoke one owned session. A foreign or already absent identifier returns false. */ - public boolean revoke(Subject subject, UUID sessionId) { - Objects.requireNonNull(subject, "subject"); Objects.requireNonNull(sessionId, "sessionId"); - return write(connection -> { - try (PreparedStatement statement = prepare(connection, "DELETE FROM ogiri_sessions WHERE " + OWNER + " AND id = ?")) { - owner(statement, subject); statement.setObject(4, sessionId); - return statement.executeUpdate() == 1; - } - }); - } - - /** Serialize with issuance and remove all this owner's sessions. Later sign-ins remain possible. */ - public int revokeAll(Subject subject) { - Objects.requireNonNull(subject, "subject"); - return write(connection -> { - lock(connection, subject); - try (PreparedStatement statement = prepare(connection, "DELETE FROM ogiri_sessions WHERE " + OWNER)) { - owner(statement, subject); return statement.executeUpdate(); - } - }); - } - - /** Delete at most batchSize expired rows. Concurrent workers skip locked rows; no leader election is needed. */ - public int cleanup(int batchSize) { - if (batchSize < 1 || batchSize > 10_000) throw new IllegalArgumentException("batchSize must be between 1 and 10000"); - return write(connection -> { - try (PreparedStatement statement = prepare(connection, """ - WITH expired AS ( - SELECT id FROM ogiri_sessions WHERE expires_at <= statement_timestamp() - ORDER BY expires_at, id LIMIT ? FOR UPDATE SKIP LOCKED - ) - DELETE FROM ogiri_sessions AS sessions USING expired WHERE sessions.id = expired.id - """)) { - statement.setInt(1, batchSize); return statement.executeUpdate(); - } - }); - } - - private T write(SqlWork work) { - try (Connection connection = connection()) { - connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); - connection.setAutoCommit(false); - try { - T result = work.apply(connection); - connection.commit(); - return result; - } catch (SQLException | RuntimeException | Error failure) { - try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); } - throw failure; - } - } catch (SQLException failure) { throw new SessionStoreException(failure); } - } - - private Connection connection() throws SQLException { - Connection connection = dataSource.getConnection(); - try { - if (!connection.getAutoCommit()) throw new SQLException("DataSource must supply auto-commit connections"); - return connection; - } catch (SQLException failure) { - try { connection.close(); } catch (SQLException close) { failure.addSuppressed(close); } - throw failure; - } - } - - private static PreparedStatement prepare(Connection connection, String sql) throws SQLException { - PreparedStatement statement = connection.prepareStatement(sql); - try { statement.setQueryTimeout(5); return statement; } - catch (SQLException failure) { statement.close(); throw failure; } - } - - private static void lock(Connection connection, Subject subject) throws SQLException { - try (PreparedStatement statement = prepare(connection, "SELECT pg_advisory_xact_lock(?)")) { - statement.setLong(1, Tokens.lockKey(subject)); statement.execute(); - } - } - - private static void owner(PreparedStatement statement, Subject subject) throws SQLException { - statement.setString(1, subject.realm()); statement.setString(2, subject.tenantId()); statement.setString(3, subject.subjectId()); - } - - private static void instant(PreparedStatement statement, int index, Instant time) throws SQLException { - statement.setObject(index, time.atOffset(ZoneOffset.UTC)); - } - - private static Session session(ResultSet rows) throws SQLException { - return new Session(rows.getObject("id", UUID.class), - new Subject(rows.getString("realm"), rows.getString("tenant_id"), rows.getString("subject_id")), - rows.getString("client"), rows.getObject("created_at", OffsetDateTime.class).toInstant(), - rows.getObject("expires_at", OffsetDateTime.class).toInstant()); - } - - @FunctionalInterface private interface SqlWork { T apply(Connection connection) throws SQLException; } -} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java b/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java index c6bf8d3..0f90ad1 100644 --- a/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java +++ b/ogiri/src/main/java/com/quantipixels/ogiri/SessionStoreException.java @@ -4,5 +4,5 @@ /** Storage is unavailable or rejected an operation. Never translate this into a successful authentication. */ public final class SessionStoreException extends RuntimeException { - SessionStoreException(java.sql.SQLException cause) { super("Session storage operation failed", cause); } + SessionStoreException(Throwable cause) { super("Session storage operation failed", cause); } } diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java b/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java index 4456e7b..487f907 100644 --- a/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Tokens.java @@ -23,13 +23,14 @@ static String generate() { static byte[] digest(String token) { if (token == null || token.length() != 47 || !token.startsWith("og1_")) return null; String encoded = token.substring(4); - if (!encoded.matches("[A-Za-z0-9_-]{43}")) return null; - byte[] bytes = Base64.getUrlDecoder().decode(encoded); + final byte[] bytes; + try { bytes = Base64.getUrlDecoder().decode(encoded); } + catch (IllegalArgumentException malformed) { return null; } if (bytes.length != 32 || !ENCODER.encodeToString(bytes).equals(encoded)) return null; return sha256().digest(token.getBytes(StandardCharsets.US_ASCII)); } - static long lockKey(Subject subject) { + static byte[] lockKey(Subject subject) { MessageDigest hash = sha256(); hash.update("ogiri-session-admission-v1".getBytes(StandardCharsets.US_ASCII)); for (String part : new String[]{subject.realm(), subject.tenantId(), subject.subjectId()}) { @@ -37,7 +38,7 @@ static long lockKey(Subject subject) { hash.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); hash.update(bytes); } - return ByteBuffer.wrap(hash.digest()).getLong(); + return hash.digest(); } private static MessageDigest sha256() { diff --git a/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql b/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql new file mode 100644 index 0000000..ff77ad4 --- /dev/null +++ b/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql @@ -0,0 +1,17 @@ +-- SPDX-License-Identifier: Apache-2.0 +-- Explicit application-owned migration template, never auto-applied by the library. +CREATE TABLE ogiri_subject_locks ( + lock_key varbinary(32) PRIMARY KEY CHECK (octet_length(lock_key) = 32) +) ENGINE=InnoDB; +CREATE TABLE ogiri_sessions ( + id varchar(36) PRIMARY KEY, + realm varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + tenant_id varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + subject_id varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL, + client varchar(255) NOT NULL, + token_hash varbinary(32) NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + created_at bigint NOT NULL, + expires_at bigint NOT NULL CHECK (expires_at > created_at) +) ENGINE=InnoDB; +CREATE INDEX ogiri_sessions_owner ON ogiri_sessions (realm, tenant_id, subject_id, expires_at); +CREATE INDEX ogiri_sessions_expiry ON ogiri_sessions (expires_at, id); diff --git a/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql b/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql index dcd01a7..1d5294c 100644 --- a/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql +++ b/ogiri/src/main/resources/META-INF/ogiri/schema-postgresql.sql @@ -1,14 +1,17 @@ -- SPDX-License-Identifier: Apache-2.0 --- Explicit template: copy into an application-owned migration. Never auto-applied by Ogiri. +-- Explicit application-owned migration template, never auto-applied by the library. +CREATE TABLE ogiri_subject_locks ( + lock_key bytea PRIMARY KEY CHECK (octet_length(lock_key) = 32) +); CREATE TABLE ogiri_sessions ( - id uuid PRIMARY KEY, + id varchar(36) PRIMARY KEY, realm varchar(63) COLLATE "C" NOT NULL, tenant_id varchar(255) COLLATE "C" NOT NULL, subject_id varchar(255) COLLATE "C" NOT NULL, client varchar(255) NOT NULL, token_hash bytea NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), - created_at timestamptz NOT NULL, - expires_at timestamptz NOT NULL CHECK (expires_at > created_at) + created_at bigint NOT NULL, + expires_at bigint NOT NULL CHECK (expires_at > created_at) ); CREATE INDEX ogiri_sessions_owner ON ogiri_sessions (realm, tenant_id, subject_id, expires_at); CREATE INDEX ogiri_sessions_expiry ON ogiri_sessions (expires_at, id); diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java similarity index 67% rename from ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java rename to ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java index a246e3b..107b180 100644 --- a/ogiri/src/test/java/com/quantipixels/ogiri/PostgresSessionsTest.java +++ b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java @@ -13,36 +13,33 @@ import java.util.UUID; import java.util.concurrent.*; import org.junit.jupiter.api.*; -import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DelegatingDataSource; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.core.io.ClassPathResource; -class PostgresSessionsTest { - private static PGSimpleDataSource dataSource; +class JdbcSessionsTest { + private static DriverManagerDataSource dataSource; private static final Subject OWNER = new Subject("users", "tenant-a", "user-42"); - private PostgresSessions sessions; + private JdbcSessions sessions; @BeforeAll static void database() throws Exception { dataSource = source(); - try (Connection connection = dataSource.getConnection(); var statement = connection.createStatement(); - var schema = PostgresSessions.class.getResourceAsStream("/META-INF/ogiri/schema-postgresql.sql")) { - assertNotNull(schema); - statement.execute("DROP TABLE IF EXISTS ogiri_sessions"); - statement.execute(new String(schema.readAllBytes(), StandardCharsets.UTF_8)); - } + sql("DROP TABLE IF EXISTS ogiri_sessions"); + sql("DROP TABLE IF EXISTS ogiri_subject_locks"); + String vendor = System.getenv("OGIRI_TEST_DATABASE"); + if (!java.util.Set.of("postgresql", "mysql").contains(vendor)) throw new IllegalArgumentException("Set OGIRI_TEST_DATABASE"); + new ResourceDatabasePopulator(new ClassPathResource("META-INF/ogiri/schema-" + vendor + ".sql")).execute(dataSource); } - private static PGSimpleDataSource source() { - PGSimpleDataSource source = new PGSimpleDataSource(); - source.setURL(java.util.Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL"), "Set OGIRI_TEST_JDBC_URL to a disposable PostgreSQL database")); - source.setUser(System.getenv("OGIRI_TEST_JDBC_USER")); - source.setPassword(System.getenv("OGIRI_TEST_JDBC_PASSWORD")); - source.setConnectTimeout(3); - source.setSocketTimeout(10); - return source; + private static DriverManagerDataSource source() { + return new DriverManagerDataSource(java.util.Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL"), "Disposable database required"), + System.getenv("OGIRI_TEST_JDBC_USER"), System.getenv("OGIRI_TEST_JDBC_PASSWORD")); } @BeforeEach void reset() throws Exception { sql("TRUNCATE ogiri_sessions"); - sessions = new PostgresSessions(dataSource); + sessions = new JdbcSessions(dataSource); } @Test void issuanceRoundTripsWithoutPersistingOrPrintingTheCredential() throws Exception { @@ -62,9 +59,15 @@ private static PGSimpleDataSource source() { } @Test void malformedAndNonCanonicalCredentialsNeverBecomeDatabaseLookups() { - PGSimpleDataSource unavailable = source(); - unavailable.setPortNumbers(new int[]{1}); - var offline = new PostgresSessions(unavailable); + var unavailable = new java.util.concurrent.atomic.AtomicBoolean(false); + var faultable = new DelegatingDataSource(dataSource) { + @Override public Connection getConnection() throws SQLException { + if (unavailable.get()) throw new SQLException("controlled unavailable store"); + return super.getConnection(); + } + }; + var offline = new JdbcSessions(faultable); + unavailable.set(true); for (String token : new String[]{"", "og1_", "og1_" + "A".repeat(42), "og1_" + "A".repeat(44), "og1_" + "A".repeat(42) + "B", "og1_" + "!".repeat(43), "Bearer " + "A".repeat(43), "x".repeat(100_000)}) { assertTrue(offline.authenticate(token).isEmpty(), "Malformed credential must fail before I/O"); } @@ -74,17 +77,29 @@ private static PGSimpleDataSource source() { @Test void authenticationDoesNotWriteOrSlideExpiryAndExpiredRowsCannotAuthenticate() throws Exception { var issued = sessions.issue(OWNER, "browser"); - String before = scalar("SELECT xmin::text FROM ogiri_sessions"); - for (int i = 0; i < 5; i++) assertEquals(issued.session(), sessions.authenticate(issued.token()).orElseThrow()); - assertEquals(before, scalar("SELECT xmin::text FROM ogiri_sessions"), "Read authentication must not create new row versions"); - sql("UPDATE ogiri_sessions SET created_at = '2000-01-01Z', expires_at = '2000-01-02Z'"); + String before = scalar("SELECT expires_at FROM ogiri_sessions"); + var readOnly = new DelegatingDataSource(dataSource) { + @Override public Connection getConnection() throws SQLException { + Connection actual = super.getConnection(); + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) -> { + if (method.getName().equals("prepareStatement") && !((String) args[0]).stripLeading().startsWith("SELECT")) + throw new AssertionError("Authentication attempted a non-read statement"); + try { return method.invoke(actual, args); } + catch (InvocationTargetException failure) { throw failure.getCause(); } + }); + } + }; + var reader = new JdbcSessions(readOnly); + for (int i = 0; i < 5; i++) assertEquals(issued.session(), reader.authenticate(issued.token()).orElseThrow()); + assertEquals(before, scalar("SELECT expires_at FROM ogiri_sessions"), "Read authentication must not create new row versions"); + sql("UPDATE ogiri_sessions SET created_at = 1, expires_at = 2"); assertTrue(sessions.authenticate(issued.token()).isEmpty()); assertTrue(sessions.list(OWNER).isEmpty()); } @Test void managementUsesEveryIdentityComponentAndNeverTrustsTheSessionIdAlone() { var a = sessions.issue(OWNER, "a"); - for (Subject foreign : List.of(new Subject("admins", "tenant-a", "user-42"), new Subject("users", "tenant-b", "user-42"), new Subject("users", "Tenant-a", "user-42"), new Subject("users", "tenant-a", "USER-42"), new Subject("users", "tenant-a", "other"), new Subject("users", "", "user-42"))) { + for (Subject foreign : List.of(new Subject("admins", "tenant-a", "user-42"), new Subject("users", "tenant-b", "user-42"), new Subject("users", "Tenant-a", "user-42"), new Subject("users", "tenant-a", "USER-42"), new Subject("users", "tenant-a ", "user-42"), new Subject("users", "tenant-a", "other"), new Subject("users", "", "user-42"))) { var b = sessions.issue(foreign, "b"); assertEquals(List.of(b.session()), sessions.list(foreign)); assertFalse(sessions.revoke(foreign, a.session().id())); @@ -111,7 +126,7 @@ private static PGSimpleDataSource source() { } @Test void admissionRejectsRatherThanEvictingAndRevocationFreesCapacity() { - var limited = new PostgresSessions(dataSource, new SessionPolicy(Duration.ofHours(1), 1)); + var limited = new JdbcSessions(dataSource, new SessionPolicy(Duration.ofHours(1), 1)); var first = limited.issue(OWNER, "phone"); assertThrows(SessionLimitException.class, () -> limited.issue(OWNER, "laptop")); assertTrue(limited.authenticate(first.token()).isPresent()); @@ -131,7 +146,7 @@ private static PGSimpleDataSource source() { results.add(workers.submit(() -> { start.await(5, TimeUnit.SECONDS); try { - new PostgresSessions(dataSource, new SessionPolicy(Duration.ofHours(1), 2)).issue(subject, "parallel"); + new JdbcSessions(dataSource, new SessionPolicy(Duration.ofHours(1), 2)).issue(subject, "parallel"); return true; } catch (SessionLimitException expected) { return false; } })); @@ -146,13 +161,13 @@ private static PGSimpleDataSource source() { @Test void cleanupIsBoundedSkipsLockedRowsAndPreservesLiveSessions() throws Exception { for (int i = 0; i < 4; i++) sessions.issue(OWNER, "expired"); - sql("UPDATE ogiri_sessions SET created_at = '2000-01-01Z', expires_at = '2000-01-02Z'"); + sql("UPDATE ogiri_sessions SET created_at = 1, expires_at = 2"); var live = sessions.issue(OWNER, "live"); try (Connection blocker = dataSource.getConnection()) { blocker.setAutoCommit(false); - try (var statement = blocker.createStatement(); var rows = statement.executeQuery("SELECT id FROM ogiri_sessions WHERE expires_at < now() ORDER BY expires_at, id LIMIT 1 FOR UPDATE")) { + try (var statement = blocker.createStatement(); var rows = statement.executeQuery("SELECT id FROM ogiri_sessions WHERE expires_at = 2 ORDER BY expires_at, id LIMIT 1 FOR UPDATE")) { assertTrue(rows.next()); - UUID locked = rows.getObject(1, UUID.class); + UUID locked = UUID.fromString(rows.getString(1)); assertEquals(2, sessions.cleanup(2)); assertEquals(3, Integer.parseInt(scalar("SELECT count(*) FROM ogiri_sessions"))); assertEquals(1, sessions.cleanup(10)); @@ -167,7 +182,7 @@ private static PGSimpleDataSource source() { } @Test void failedCommitRollsBackAndNeverReturnsACredential() { - PGSimpleDataSource failing = new PGSimpleDataSource() { + DelegatingDataSource failing = new DelegatingDataSource(dataSource) { @Override public Connection getConnection() throws SQLException { Connection actual = dataSource.getConnection(); return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) -> { @@ -177,25 +192,28 @@ private static PGSimpleDataSource source() { }); } }; - assertThrows(SessionStoreException.class, () -> new PostgresSessions(failing).issue(OWNER, "phone")); + assertThrows(SessionStoreException.class, () -> new JdbcSessions(failing).issue(OWNER, "phone")); assertTrue(sessions.list(OWNER).isEmpty()); assertTrue(sessions.authenticate(sessions.issue(OWNER, "retry").token()).isPresent()); } - @Test void callerOwnedTransactionsAreRejectedForAuthenticationAndMutation() throws Exception { - var valid = sessions.issue(OWNER, "existing"); - PGSimpleDataSource enlisted = new PGSimpleDataSource() { - @Override public Connection getConnection() throws SQLException { - Connection connection = dataSource.getConnection(); - connection.setAutoCommit(false); - return connection; - } - }; - var invalid = new PostgresSessions(enlisted); - assertThrows(SessionStoreException.class, () -> invalid.authenticate(valid.token())); - assertThrows(SessionStoreException.class, () -> invalid.list(OWNER)); - assertThrows(SessionStoreException.class, () -> invalid.issue(OWNER, "new")); - assertEquals(List.of(valid.session()), sessions.list(OWNER)); + @Test void springTransactionsDoNotUndoCommittedSessionChangesOrCacheRevocation() { + var outer = new org.springframework.transaction.support.TransactionTemplate( + new org.springframework.jdbc.support.JdbcTransactionManager(dataSource)); + outer.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ); + var jdbc = new org.springframework.jdbc.core.JdbcTemplate(dataSource); + var issued = outer.execute(status -> { + jdbc.queryForObject("SELECT count(*) FROM ogiri_sessions", Integer.class); + var fresh = sessions.issue(OWNER, "committed independently"); + assertTrue(sessions.authenticate(fresh.token()).isPresent()); + assertTrue(sessions.revoke(OWNER, fresh.session().id())); + assertTrue(sessions.authenticate(fresh.token()).isEmpty()); + var survivor = sessions.issue(OWNER, "survives outer rollback"); + status.setRollbackOnly(); + return survivor; + }); + assertTrue(sessions.authenticate(issued.token()).isPresent()); + assertEquals(List.of(issued.session()), sessions.list(OWNER)); } @Test void invalidResourceBoundsAreRejected() { diff --git a/pom.xml b/pom.xml index 8b948ec..823a0bb 100644 --- a/pom.xml +++ b/pom.xml @@ -5,22 +5,22 @@ 0.1.0 pom Ogiri - Revocable opaque sessions for PostgreSQL, with optional Spring Security integration. + Spring Boot opaque sessions backed by PostgreSQL or MySQL. https://github.com/quantipixels/ogiri Apache License 2.0https://www.apache.org/licenses/LICENSE-2.0 quantipixelsQuanti Pixels https://github.com/quantipixels/ogiriscm:git:https://github.com/quantipixels/ogiri.git - ogiriogiri-spring-security + ogiriogiri-spring-boot-starter 17 UTF-8 2026-09-07T00:00:00Z - 7.1.1 + 4.1.1 6.1.3 42.7.13 - org.springframework.securityspring-security-bom${spring-security.version}pomimport + org.springframework.bootspring-boot-dependencies${spring-boot.version}pomimport org.junitjunit-bom${junit.version}pomimport org.postgresqlpostgresql${postgresql.version} From c5cefd6db48d45d41a59df21b8a2d5d490fc58d8 Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 08:12:20 +0000 Subject: [PATCH 04/13] build: prepare opt-in Central bundles and reproducible authentication measurements --- PUBLISHING.md | 18 +++++ .../ogiri/JdbcSessionsBenchmark.java | 69 +++++++++++++++++++ pom.xml | 17 +++++ 3 files changed, 104 insertions(+) create mode 100644 PUBLISHING.md create mode 100644 ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsBenchmark.java diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..486ee2e --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,18 @@ +# Publish Ogiri + +The `release` profile prepares the parent POM and both code artifacts, including sources, Javadoc, signatures and checksums. Normal `install`/CI never uploads to Central. Version is 0.1.0; no historical tag is changed. + +Run both database contract suites and the independent consumer, review the resolved dependency scan, and pin the exact commit before release. Configure Maven server ID `central` with your Sonatype user-token credentials outside the repository. Use GnuPG agent or `MAVEN_GPG_PASSPHRASE`; never put a private key/password in a POM, command argument or log. The namespace must be owned/verified in Central. + +```sh +# Local unsigned packaging rehearsal; this does NOT prove release signatures or Central acceptance. +mvn -Prelease -Dgpg.skip=true deploy +# Publisher's local signed bundle. Default central.skipPublishing=true prevents upload. +mvn -Prelease deploy +# Only with explicit release authority: upload for Central validation, but do not auto-publish. +mvn -Prelease -Dcentral.skipPublishing=false deploy +``` + +The last command uploads and waits for validation. `autoPublish=false` leaves final publication to the Central Portal. All three POM coordinates (parent, core, starter) must be included. After publication verify each POM/JAR resolves from Central with a fresh Maven repository and rerun the consumer. Do not announce publication merely because `install` or bundle generation passed. + +Central's official plugin supports `skipPublishing` for bundle-only operation and manual publication after validation. See [Sonatype Maven publishing](https://central.sonatype.org/publish/publish-portal-maven/) and [Maven GPG signing](https://maven.apache.org/plugins/maven-gpg-plugin/sign-mojo.html). Credentials/signatures/namespace ownership are publisher-only verification, not inferred from this repository. diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsBenchmark.java b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsBenchmark.java new file mode 100644 index 0000000..b5dee65 --- /dev/null +++ b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsBenchmark.java @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.quantipixels.ogiri; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import org.springframework.jdbc.core.JdbcTemplate; + +/** Opt-in measurement harness; not a timing-sensitive correctness test. Uses the disposable test DB. */ +public final class JdbcSessionsBenchmark { + public static void main(String[] args) throws Exception { + String url = Objects.requireNonNull(System.getenv("OGIRI_TEST_JDBC_URL")); + if (!url.contains("ogiri_test")) throw new IllegalArgumentException("Benchmark requires an ogiri_test disposable database"); + var config = new HikariConfig(); + config.setJdbcUrl(url); config.setUsername(System.getenv("OGIRI_TEST_JDBC_USER")); + config.setPassword(System.getenv("OGIRI_TEST_JDBC_PASSWORD")); config.setMaximumPoolSize(8); + config.setConnectionTimeout(3000); + try (var source = new HikariDataSource(config)) { + var jdbc = new JdbcTemplate(source); + String realm = "benchmark-" + UUID.randomUUID(); + String token = Tokens.generate(); + try { + long now = System.currentTimeMillis(); + List rows = new ArrayList<>(); + for (int i = 0; i < 10_000; i++) rows.add(new Object[]{UUID.randomUUID().toString(), realm, "", "user-" + i, + "benchmark", now, now + 3_600_000, Tokens.digest(i == 0 ? token : Tokens.generate())}); + jdbc.batchUpdate("INSERT INTO ogiri_sessions (id,realm,tenant_id,subject_id,client,created_at,expires_at,token_hash) VALUES (?,?,?,?,?,?,?,?)", rows); + var sessions = new JdbcSessions(source); + for (int i = 0; i < 1000; i++) if (sessions.authenticate(token).isEmpty()) throw new AssertionError("Warmup failed"); + List results = new ArrayList<>(); + for (int threads : new int[]{1, 8}) { + int perThread = 2000; + var executor = Executors.newFixedThreadPool(threads); + try { + var barrier = new CyclicBarrier(threads + 1); + List> futures = new ArrayList<>(); + for (int t = 0; t < threads; t++) futures.add(executor.submit(() -> { + long[] elapsed = new long[perThread]; barrier.await(); + for (int i = 0; i < perThread; i++) { + long start = System.nanoTime(); + if (sessions.authenticate(token).isEmpty()) throw new AssertionError("Authentication failed"); + elapsed[i] = System.nanoTime() - start; + } + return elapsed; + })); + long start = System.nanoTime(); barrier.await(); + long[] values = new long[threads * perThread]; int offset = 0; + for (var future : futures) { long[] value = future.get(60, TimeUnit.SECONDS); System.arraycopy(value, 0, values, offset, value.length); offset += value.length; } + double seconds = (System.nanoTime() - start) / 1e9; + Arrays.sort(values); + results.add(String.format(Locale.ROOT, + "{\"threads\":%d,\"samples\":%d,\"operationsPerSecond\":%.1f,\"p50Micros\":%.1f,\"p95Micros\":%.1f,\"p99Micros\":%.1f}", + threads, values.length, values.length / seconds, values[values.length/2]/1000.0, + values[(int)(values.length*.95)]/1000.0, values[(int)(values.length*.99)]/1000.0)); + } finally { executor.shutdownNow(); executor.awaitTermination(10, TimeUnit.SECONDS); } + } + String database = System.getenv("OGIRI_TEST_DATABASE"); + String output = "{\"database\":\"" + database + "\",\"java\":\"" + System.getProperty("java.version") + + "\",\"rows\":10000,\"warmup\":1000,\"poolSize\":8,\"boundary\":\"local session lookup, excluding account directory and HTTP\",\"results\":[" + + String.join(",", results) + "]}"; + Path target = Path.of("target"); Files.createDirectories(target); + Files.writeString(target.resolve("benchmark-" + database + ".json"), output + "\n"); + System.out.println(output); + } finally { jdbc.update("DELETE FROM ogiri_sessions WHERE realm = ?", realm); } + } + } +} diff --git a/pom.xml b/pom.xml index 823a0bb..6c84bc1 100644 --- a/pom.xml +++ b/pom.xml @@ -31,4 +31,21 @@ org.apache.maven.pluginsmaven-source-plugin3.3.1sourcesjar-no-fork org.apache.maven.pluginsmaven-javadoc-plugin3.11.2all,-missingtruejavadocjar + + release + true + + org.apache.maven.pluginsmaven-gpg-plugin3.2.8 + true + signverifysign + + org.sonatype.centralcentral-publishing-maven-plugin0.11.0 + true + centralfalse + ${central.skipPublishing} + + + + + From 38a88fd8b0638ade468ed1e99f8ac5081a72e77b Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 08:41:56 +0000 Subject: [PATCH 05/13] fix: compose session transactions with the host JPA manager --- README.md | 2 + SECURITY.md | 2 + examples/spring-app/pom.xml | 1 + .../example/ogiri/JpaTransactionTest.java | 57 +++++++++++++++++++ .../ogiri/spring/OgiriAutoConfiguration.java | 10 +++- .../com/quantipixels/ogiri/JdbcSessions.java | 15 ++++- 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 examples/spring-app/src/test/java/example/ogiri/JpaTransactionTest.java diff --git a/README.md b/README.md index 45c00eb..f24e934 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,5 @@ No refresh/rotation protocol, cookie transport, registration, recovery orchestra The [independent example](examples/spring-app) consumes the actual installed artifacts. It exercises both the zero-plumbing default and existing multi-chain applications against both database engines. Production performance is not inferred from line counts; the opt-in benchmark measures a defined local storage workload. Licensed under Apache-2.0. + +The starter reuses the application transaction manager, including Spring JPA. With the core alone, pass the manager for the supplied DataSource to `new JdbcSessions(dataSource, policy, transactionManager)`. The two-argument constructor creates a JDBC manager and is intended for JDBC-only transaction contexts. Multiple data sources or managers require an explicitly selected `JdbcSessions` bean; do not select an unrelated manager. diff --git a/SECURITY.md b/SECURITY.md index 0bc7ba4..65b0e1b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,3 +33,5 @@ No authentication-positive cache exists. Storage/directory outages fail closed a `IssuedSession` and login-request string rendering are redacted. The token accessor is deliberately sensitive; never log/serialize it, put it in URLs, or send it to analytics. JSON endpoints return only metadata with no-store responses. Spring/JDK strings can retain credentials in memory; no memory-erasure guarantee is claimed. Tests use disposable databases and destructive fixture setup. Functional tests, selected mutation probes, CodeQL, resolved-dependency scanning and local benchmarks cover different boundaries. No one signal proves absence of vulnerabilities, every possible race, or production capacity. Human review is still appropriate before production adoption. + +The starter uses the host transaction manager to suspend and resume JDBC or JPA state correctly. Direct core users with JPA must pass the corresponding manager. Session transactions remain independent; they do not make password reset and concurrent sign-in atomic. diff --git a/examples/spring-app/pom.xml b/examples/spring-app/pom.xml index 65af1c9..1f2c5fb 100644 --- a/examples/spring-app/pom.xml +++ b/examples/spring-app/pom.xml @@ -7,6 +7,7 @@ com.quantipixels.ogiriogiri-spring-boot-starter${ogiri.version} org.postgresqlpostgresqlruntime org.springframework.bootspring-boot-starter-testtest + org.springframework.bootspring-boot-starter-data-jpatest mysql com.mysqlmysql-connector-jruntime diff --git a/examples/spring-app/src/test/java/example/ogiri/JpaTransactionTest.java b/examples/spring-app/src/test/java/example/ogiri/JpaTransactionTest.java new file mode 100644 index 0000000..5437ca6 --- /dev/null +++ b/examples/spring-app/src/test/java/example/ogiri/JpaTransactionTest.java @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +package example.ogiri; + +import static org.junit.jupiter.api.Assertions.*; +import com.quantipixels.ogiri.*; +import jakarta.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.orm.jpa.EntityManagerFactoryUtils; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest(properties = { + "spring.datasource.url=${OGIRI_TEST_JDBC_URL}", + "spring.datasource.username=${OGIRI_TEST_JDBC_USER}", + "spring.datasource.password=${OGIRI_TEST_JDBC_PASSWORD}", + "spring.datasource.hikari.maximum-pool-size=3", "spring.jpa.open-in-view=false", + "demo.password=test-password" +}) +class JpaTransactionTest { + @Autowired JdbcSessions sessions; + @Autowired PlatformTransactionManager transactions; + @Autowired EntityManagerFactory entityManagers; + @Autowired DataSource source; + + @Test void nativeJpaTransactionsCannotUndoSessionChangesOrRetainStaleAuthentication() { + assertInstanceOf(JpaTransactionManager.class, transactions); + var owner = new Subject("users", "", "jpa-contract"); + sessions.revokeAll(owner); + var jdbc = new JdbcTemplate(source); + var outside = sessions.issue(owner, "before transaction"); + var outer = new TransactionTemplate(transactions); + outer.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ); + var survivor = outer.execute(status -> { + var em = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagers); + assertNotNull(em); + em.createNativeQuery("SELECT count(*) FROM ogiri_sessions").getSingleResult(); + assertTrue(sessions.authenticate(outside.token()).isPresent()); + assertTrue(sessions.revoke(owner, outside.session().id())); + assertTrue(sessions.authenticate(outside.token()).isEmpty(), "Must not reuse the host's repeatable-read snapshot"); + var committed = sessions.issue(owner, "independent"); + assertSame(em, EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagers)); + // This application write must still roll back after Ogiri resumes the original JPA transaction. + jdbc.update("UPDATE ogiri_sessions SET client = ? WHERE id = ?", "host rollback", committed.session().id().toString()); + status.setRollbackOnly(); + return committed; + }); + assertNotNull(survivor); + assertEquals("independent", sessions.authenticate(survivor.token()).orElseThrow().client()); + assertTrue(sessions.authenticate(outside.token()).isEmpty()); + sessions.revokeAll(owner); + } +} diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java index e4519ad..1119798 100644 --- a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java @@ -23,7 +23,9 @@ /** Boot owns discovery, configuration binding, pooling and native Security integration. */ @AutoConfiguration( - afterName = "org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", + afterName = {"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", + "org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration", + "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration"}, beforeName = {"org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration", "org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration"}) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @@ -37,7 +39,11 @@ SessionPolicy ogiriPolicy(OgiriProperties properties) { } @Bean @ConditionalOnMissingBean - JdbcSessions ogiriSessions(DataSource source, SessionPolicy policy) { return new JdbcSessions(source, policy); } + JdbcSessions ogiriSessions(DataSource source, SessionPolicy policy, + org.springframework.beans.factory.ObjectProvider managers) { + var manager = managers.getIfAvailable(); + return manager == null ? new JdbcSessions(source, policy) : new JdbcSessions(source, policy, manager); + } @Bean @ConditionalOnMissingBean PasswordEncoder ogiriPasswordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java b/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java index fbb3a54..6047f49 100644 --- a/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java +++ b/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java @@ -18,6 +18,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.support.JdbcTransactionManager; import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionException; import org.springframework.transaction.support.TransactionTemplate; @@ -40,6 +41,11 @@ public final class JdbcSessions { public JdbcSessions(DataSource source) { this(source, SessionPolicy.defaults()); } public JdbcSessions(DataSource source, SessionPolicy policy) { + this(source, policy, jdbcTransactions(source)); + } + + /** Use the application's transaction manager for this DataSource, including JPA-backed hosts. */ + public JdbcSessions(DataSource source, SessionPolicy policy, PlatformTransactionManager manager) { this.policy = Objects.requireNonNull(policy, "policy"); Objects.requireNonNull(source, "source"); if (source instanceof org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy) @@ -48,8 +54,7 @@ public JdbcSessions(DataSource source, SessionPolicy policy) { this.jdbc = new JdbcTemplate(source); this.jdbc.setQueryTimeout(5); this.named = new NamedParameterJdbcTemplate(jdbc); - var manager = new JdbcTransactionManager(source); - manager.setRollbackOnCommitFailure(true); + Objects.requireNonNull(manager, "manager"); this.mutations = new TransactionTemplate(manager); mutations.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); mutations.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); @@ -58,6 +63,12 @@ public JdbcSessions(DataSource source, SessionPolicy policy) { reads.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); } + private static JdbcTransactionManager jdbcTransactions(DataSource source) { + var manager = new JdbcTransactionManager(source); + manager.setRollbackOnCommitFailure(true); + return manager; + } + /** Issue only after the caller has authenticated and authorized the full subject. */ public IssuedSession issue(Subject subject, String client) { Objects.requireNonNull(subject, "subject"); From b1d5b9d11890ab48d52ba2c32bd5af2cddebb6bf Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 08:50:11 +0000 Subject: [PATCH 06/13] fix: order the starter before Spring Boot servlet security defaults --- .../quantipixels/ogiri/spring/OgiriAutoConfiguration.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java index 1119798..b2e33d8 100644 --- a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java @@ -26,8 +26,9 @@ afterName = {"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration", "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration"}, - beforeName = {"org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration", - "org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration"}) + before = {org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration.class, + org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration.class, + org.springframework.boot.security.oauth2.server.resource.autoconfigure.web.OAuth2ResourceServerWebSecurityAutoConfiguration.class}) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @ConditionalOnProperty(prefix = "ogiri", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties(OgiriProperties.class) From 55fbbeda28dd0ad127fd7a72bbf7c9e7f1c6b708 Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 08:54:40 +0000 Subject: [PATCH 07/13] fix: express MySQL expiry validation as a table constraint --- ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql b/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql index ff77ad4..6bc71b6 100644 --- a/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql +++ b/ogiri/src/main/resources/META-INF/ogiri/schema-mysql.sql @@ -11,7 +11,8 @@ CREATE TABLE ogiri_sessions ( client varchar(255) NOT NULL, token_hash varbinary(32) NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), created_at bigint NOT NULL, - expires_at bigint NOT NULL CHECK (expires_at > created_at) + expires_at bigint NOT NULL, + CONSTRAINT ogiri_expiry_after_creation CHECK (expires_at > created_at) ) ENGINE=InnoDB; CREATE INDEX ogiri_sessions_owner ON ogiri_sessions (realm, tenant_id, subject_id, expires_at); CREATE INDEX ogiri_sessions_expiry ON ogiri_sessions (expires_at, id); From 2225525c88689fb708fa27ff9a9e0c3a3db40cc2 Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 09:00:39 +0000 Subject: [PATCH 08/13] fix: keep database-specific timeout units out of shared Boot configuration --- README.md | 8 ++++---- examples/spring-app/README.md | 2 ++ .../spring-app/src/main/resources/application.properties | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f24e934..b176e94 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,11 @@ Both engines use the same lifecycle, admission rules and contract suite. SQL var Two tables are required. `ogiri_sessions` holds hashes and metadata. `ogiri_subject_locks` provides stable row locks for admission and account-wide revocation. Lock rows retain one digest per identity; do not delete them while writers run, as that can split the serialization boundary. They contain no credential or direct identity text. This retained state is the explicit cost of portable session-cap enforcement, not a cache. -Use the authoritative primary and a normal underlying pool, not a transaction-aware or replica-routing proxy. Spring `JdbcTransactionManager` and `TransactionTemplate` own commit, rollback and resource restoration. Session mutations commit in independent `REQUIRES_NEW` transactions; reads suspend an outer JDBC transaction so a stale snapshot cannot restore revoked credentials. An outer transaction that already holds a connection needs spare pool capacity. Ordinary calls work with a one-connection pool. No automatic retry pretends to resolve an ambiguous commit. +Use the authoritative primary and a normal underlying pool, not a transaction-aware or replica-routing proxy. Spring transaction management and `TransactionTemplate` own commit, rollback and resource restoration. Session mutations commit in independent `REQUIRES_NEW` transactions; reads suspend an outer JDBC or JPA transaction so a stale snapshot cannot restore revoked credentials. An outer transaction that already holds a connection needs spare pool capacity. Ordinary calls work with a one-connection pool. No automatic retry pretends to resolve an ambiguous commit. -Each authentication performs one indexed session read, no writes and no positive cache, followed by the current account lookup in the Spring adapter. Malformed credentials fail before query execution. Five-second SQL timeouts do not replace connection, socket or HTTP timeouts. Configure those through your pool/server. Authentication expiry does not depend on cleanup. +The starter reuses the application transaction manager, including Spring JPA. With the core alone, pass the manager for the supplied DataSource to `new JdbcSessions(dataSource, policy, transactionManager)`. The two-argument constructor creates a JDBC manager and is intended for JDBC-only transaction contexts. Multiple data sources or managers require an explicitly selected `JdbcSessions` bean; do not select an unrelated manager. + +Each authentication performs one indexed session read, no writes and no positive cache, followed by the current account lookup in the Spring adapter. Malformed credentials fail before query execution. Five-second SQL timeouts do not replace connection, socket or HTTP timeouts. Configure those through your pool/server. JDBC driver timeout units differ: PostgreSQL `socketTimeout` uses seconds; MySQL uses milliseconds. Do not share that numeric setting between drivers. Authentication expiry does not depend on cleanup. Schedule `JdbcSessions.cleanup(batchSize)` in your existing jobs. It locks a bounded ID page with `SKIP LOCKED` and deletes it in the same transaction; concurrent workers need no leader lease. Limit job runtime and stop when fewer than a page is returned. Core-only callers can depend on `ogiri` and construct `JdbcSessions(dataSource, policy)` without Boot. @@ -97,5 +99,3 @@ No refresh/rotation protocol, cookie transport, registration, recovery orchestra The [independent example](examples/spring-app) consumes the actual installed artifacts. It exercises both the zero-plumbing default and existing multi-chain applications against both database engines. Production performance is not inferred from line counts; the opt-in benchmark measures a defined local storage workload. Licensed under Apache-2.0. - -The starter reuses the application transaction manager, including Spring JPA. With the core alone, pass the manager for the supplied DataSource to `new JdbcSessions(dataSource, policy, transactionManager)`. The two-argument constructor creates a JDBC manager and is intended for JDBC-only transaction contexts. Multiple data sources or managers require an explicitly selected `JdbcSessions` bean; do not select an unrelated manager. diff --git a/examples/spring-app/README.md b/examples/spring-app/README.md index e8bc9da..eebb0ae 100644 --- a/examples/spring-app/README.md +++ b/examples/spring-app/README.md @@ -7,3 +7,5 @@ Install the library first. Provision `META-INF/ogiri/schema-postgresql.sql` or ` Sign in with `POST /auth/sign-in`, JSON username `demo`, your password and a client label; include `X-Requested-With: Ogiri`. Use the returned Authorization header for `/me`, `/auth/session` and `/auth/sessions`. `/admin` demonstrates method-level role denial. `ConsumerTest` verifies the default path with a one-connection pool. `HostChainTest` demonstrates two application-owned chains, a custom tenant/ID mapping, disabled endpoints, preserved CSRF and directory outages. Neither test assumes H2 represents a production database. + +Configure driver-specific socket timeouts in the JDBC URL: PostgreSQL uses `socketTimeout=10` for ten seconds, while MySQL uses `socketTimeout=10000`. The common application properties intentionally do not set a driver-specific socket timeout. See [pgJDBC parameters](https://jdbc.postgresql.org/documentation/use/) and [Connector/J networking](https://dev.mysql.com/doc/connector-j/en/connector-j-connp-props-networking.html). diff --git a/examples/spring-app/src/main/resources/application.properties b/examples/spring-app/src/main/resources/application.properties index 2fff910..0ecd247 100644 --- a/examples/spring-app/src/main/resources/application.properties +++ b/examples/spring-app/src/main/resources/application.properties @@ -3,6 +3,5 @@ spring.datasource.username=${OGIRI_JDBC_USER} spring.datasource.password=${OGIRI_JDBC_PASSWORD} spring.datasource.hikari.maximum-pool-size=5 spring.datasource.hikari.connection-timeout=3000 -spring.datasource.hikari.data-source-properties.socketTimeout=10 demo.password=${OGIRI_DEMO_PASSWORD} server.error.include-message=never From 09126f274125c8c13071a09885118f1407adeb1d Mon Sep 17 00:00:00 2001 From: OpenAI Date: Mon, 7 Sep 2026 09:10:55 +0000 Subject: [PATCH 09/13] fix: align embedded Tomcat with published security patches --- examples/spring-app/pom.xml | 2 +- pom.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/spring-app/pom.xml b/examples/spring-app/pom.xml index 1f2c5fb..9d32aa0 100644 --- a/examples/spring-app/pom.xml +++ b/examples/spring-app/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.springframework.bootspring-boot-starter-parent4.1.1 com.quantipixels.ogiri.examplesspring-app0.1.0 - 170.1.0 + 1711.0.250.1.0 com.quantipixels.ogiriogiri-spring-boot-starter${ogiri.version} org.postgresqlpostgresqlruntime diff --git a/pom.xml b/pom.xml index 6c84bc1..514dd69 100644 --- a/pom.xml +++ b/pom.xml @@ -17,9 +17,14 @@ 2026-09-07T00:00:00Z 4.1.1 6.1.3 + 11.0.25 42.7.13 + + org.apache.tomcat.embedtomcat-embed-core${tomcat.version} + org.apache.tomcat.embedtomcat-embed-el${tomcat.version} + org.apache.tomcat.embedtomcat-embed-websocket${tomcat.version} org.springframework.bootspring-boot-dependencies${spring-boot.version}pomimport org.junitjunit-bom${junit.version}pomimport org.postgresqlpostgresql${postgresql.version} From d3bcd23411eda70f799e958a7db4299f22ffd816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAwa=E1=B9=A3=C3=A8y=C3=AD?= <52331341+mosobande@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:25:10 +0100 Subject: [PATCH 10/13] ci: gate Boot releases on PostgreSQL MySQL and resolved dependency evidence --- .github/workflows/build.yml | 145 +++++++++++++++++++++++++++--------- PUBLISHING.md | 33 +++++--- README.md | 4 + 3 files changed, 139 insertions(+), 43 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b42e10..6a1e9ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,14 @@ on: pull_request: push: branches: [ori, build/ogiri-0.1.0] + schedule: + - cron: '31 5 * * 1' + workflow_dispatch: + inputs: + benchmark: + description: Measure local session lookup performance + type: boolean + default: false permissions: contents: read concurrency: @@ -10,30 +18,44 @@ concurrency: cancel-in-progress: true jobs: verify: - name: Java ${{ matrix.java }} / PostgreSQL ${{ matrix.postgres }} + name: Java ${{ matrix.java }} / ${{ matrix.image }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: + java: ['17', '25'] + database: [postgresql, mysql] include: - - java: '17' - postgres: '16' - - java: '25' - postgres: '18' + - database: postgresql + image: postgres:16 + port: 5432 + health: pg_isready -U ogiri -d ogiri_test + url: jdbc:postgresql://localhost:5432/ogiri_test?socketTimeout=10 + - database: mysql + image: mysql:8.4 + port: 3306 + health: mysqladmin ping -h 127.0.0.1 + url: jdbc:mysql://localhost:3306/ogiri_test?allowPublicKeyRetrieval=true&sslMode=DISABLED&socketTimeout=10000 services: - postgres: - image: postgres:${{ matrix.postgres }} + database: + image: ${{ matrix.image }} env: POSTGRES_USER: ogiri POSTGRES_PASSWORD: ogiri POSTGRES_DB: ogiri_test - ports: ['5432:5432'] + MYSQL_ROOT_PASSWORD: root-test-only + MYSQL_USER: ogiri + MYSQL_PASSWORD: ogiri + MYSQL_DATABASE: ogiri_test + ports: + - ${{ matrix.port }}:${{ matrix.port }} options: >- - --health-cmd "pg_isready -U ogiri -d ogiri_test" - --health-interval 5s --health-timeout 5s --health-retries 10 + --health-cmd "${{ matrix.health }}" + --health-interval 5s --health-timeout 5s --health-retries 30 env: - OGIRI_TEST_JDBC_URL: jdbc:postgresql://localhost:5432/ogiri_test + OGIRI_TEST_DATABASE: ${{ matrix.database }} + OGIRI_TEST_JDBC_URL: ${{ matrix.url }} OGIRI_TEST_JDBC_USER: ogiri OGIRI_TEST_JDBC_PASSWORD: ogiri steps: @@ -45,51 +67,106 @@ jobs: distribution: temurin java-version: ${{ matrix.java }} cache: maven - - name: Verify and install the two library artifacts - run: mvn --batch-mode --no-transfer-progress clean install - - name: Apply the PostgreSQL schema from the built JAR + - name: Verify and install the library artifacts + run: mvn -B -ntp clean install + - name: Apply the schema from the built JAR env: PGPASSWORD: ogiri + MYSQL_PWD: ogiri run: | set -euo pipefail - psql -h localhost -U ogiri -d ogiri_test -v ON_ERROR_STOP=1 -c 'DROP TABLE ogiri_sessions' - unzip -p ogiri/target/ogiri-0.1.0.jar META-INF/ogiri/schema-postgresql.sql | psql -h localhost -U ogiri -d ogiri_test -v ON_ERROR_STOP=1 - - name: Verify the independently resolved consumer over real HTTP - run: mvn --batch-mode --no-transfer-progress -f examples/spring-app/pom.xml verify - - name: Inspect packaged API documentation and test evidence + version=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version) + jar="ogiri/target/ogiri-$version.jar" + if [ "$OGIRI_TEST_DATABASE" = postgresql ]; then + psql -h localhost -U ogiri -d ogiri_test -v ON_ERROR_STOP=1 -c 'DROP TABLE ogiri_sessions, ogiri_subject_locks' + unzip -p "$jar" META-INF/ogiri/schema-postgresql.sql | psql -h localhost -U ogiri -d ogiri_test -v ON_ERROR_STOP=1 + else + mysql -h 127.0.0.1 -u ogiri ogiri_test -e 'DROP TABLE ogiri_sessions, ogiri_subject_locks' + unzip -p "$jar" META-INF/ogiri/schema-mysql.sql | mysql -h 127.0.0.1 -u ogiri ogiri_test + fi + - name: Verify independently resolved Boot consumers over HTTP and JPA + run: mvn -B -ntp -Pmysql -f examples/spring-app/pom.xml verify + - name: Inspect package contents and executed test evidence run: | python - <<'PY' from pathlib import Path from zipfile import ZipFile import json, xml.etree.ElementTree as E + ns={'m':'http://maven.apache.org/POM/4.0.0'} + version=E.parse('pom.xml').find('m:version',ns).text results=[] - for module in ('ogiri','ogiri-spring-security','examples/spring-app'): + for module in ('ogiri','ogiri-spring-boot-starter','examples/spring-app'): reports=list(Path(module).glob('target/surefire-reports/TEST-*.xml')) - assert reports, f'Missing test evidence: {module}' + assert reports, module for report in reports: root=E.parse(report).getroot() - counts={key:int(root.get(key,0)) for key in ('tests','failures','errors','skipped')} + counts={k:int(root.get(k,0)) for k in ('tests','failures','errors','skipped')} assert counts['tests'] and not any(counts[k] for k in ('failures','errors','skipped')), (module,counts) results.append({'module':module,'suite':root.get('name'),**counts}) - for module in ('ogiri','ogiri-spring-security'): - with ZipFile(f'{module}/target/{module}-0.1.0-javadoc.jar') as jar: - assert any(name.endswith('.html') for name in jar.namelist()), module - with ZipFile(f'{module}/target/{module}-0.1.0-sources.jar') as jar: - assert any(name.endswith('.java') for name in jar.namelist()), module - with ZipFile('ogiri/target/ogiri-0.1.0.jar') as jar: - assert 'META-INF/ogiri/schema-postgresql.sql' in jar.namelist() - assert not any(name.startswith('db/migration/') or name.endswith('.kotlin_module') for name in jar.namelist()) + for module in ('ogiri','ogiri-spring-boot-starter'): + for classifier,suffix in [('sources','.java'),('javadoc','.html')]: + with ZipFile(f'{module}/target/{module}-{version}-{classifier}.jar') as jar: + assert any(n.endswith(suffix) for n in jar.namelist()), module + with ZipFile(f'ogiri/target/ogiri-{version}.jar') as jar: + for vendor in ('postgresql','mysql'): assert f'META-INF/ogiri/schema-{vendor}.sql' in jar.namelist() + assert not any(n.startswith('db/migration/') for n in jar.namelist()) + with ZipFile(f'ogiri-spring-boot-starter/target/ogiri-spring-boot-starter-{version}.jar') as jar: + metadata=json.loads(jar.read('META-INF/spring-configuration-metadata.json')) + names={p['name'] for p in metadata['properties']} + assert {'ogiri.enabled','ogiri.base-path','ogiri.endpoints-enabled','ogiri.lifetime','ogiri.maximum-sessions','ogiri.realm'} <= names + imports=jar.read('META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports').decode() + assert 'com.quantipixels.ogiri.spring.OgiriAutoConfiguration' in imports Path('target').mkdir(exist_ok=True) Path('target/verification.json').write_text(json.dumps(results,indent=2)+'\n') print(json.dumps(results,indent=2)) PY + - name: Optional local session lookup benchmark + if: ${{ github.event_name == 'workflow_dispatch' && inputs.benchmark }} + run: >- + mvn -B -ntp -pl ogiri org.codehaus.mojo:exec-maven-plugin:3.5.0:java + -Dexec.mainClass=com.quantipixels.ogiri.JdbcSessionsBenchmark -Dexec.classpathScope=test + - name: Inventory resolved runtime dependencies + if: ${{ matrix.java == '17' && matrix.database == 'postgresql' }} + run: | + mvn -B -ntp org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DincludeTestScope=false + mvn -B -ntp -Pmysql -f examples/spring-app/pom.xml org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -DincludeTestScope=false + cp target/bom.json target/library.cdx.json + cp examples/spring-app/target/bom.json target/consumer.cdx.json + - name: Check runtime components against OSV + if: ${{ matrix.java == '17' && matrix.database == 'postgresql' }} + run: | + set -euo pipefail + curl --fail --silent --show-error --location https://github.com/google/osv-scanner/releases/download/v2.5.1/osv-scanner_linux_amd64 -o "$RUNNER_TEMP/osv-scanner" + echo "f9f25499a2c8cc367b3af45df2ea7eeca7fbccceab9c35079968f4b3652194be $RUNNER_TEMP/osv-scanner" | sha256sum --check + chmod +x "$RUNNER_TEMP/osv-scanner" + "$RUNNER_TEMP/osv-scanner" scan source --format=json -L target/library.cdx.json -L target/consumer.cdx.json > target/vulnerabilities.json + - name: Rehearse the unsigned local Central bundle + if: ${{ matrix.java == '17' && matrix.database == 'postgresql' }} + run: | + set -euo pipefail + mvn -B -ntp -Prelease -Dgpg.skip=true -DskipTests verify \ + org.apache.maven.plugins:maven-deploy-plugin:3.1.4:deploy \ + -DaltDeploymentRepository="rehearsal::file://$PWD/target/release-repository" + (cd target/release-repository && zip -qr ../ogiri-unsigned-bundle.zip com -x '*/maven-metadata*') + python - <<'PYCODE' + from zipfile import ZipFile + with ZipFile('target/ogiri-unsigned-bundle.zip') as z: + names=z.namelist() + assert len([n for n in names if n.endswith('.pom')])==3 + assert len([n for n in names if n.endswith('.jar')])==6 + for n in names: + if n.endswith(('.pom','.jar')): + assert n+'.sha1' in names and n+'.md5' in names, n + PYCODE - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: verification-java${{ matrix.java }}-postgres${{ matrix.postgres }} + name: verification-java${{ matrix.java }}-${{ matrix.database }} path: | - target/verification.json + target/*.json **/target/surefire-reports/TEST-*.xml + **/target/benchmark-*.json + target/ogiri-unsigned-bundle.zip ogiri/target/*.jar - ogiri-spring-security/target/*.jar + ogiri-spring-boot-starter/target/*.jar retention-days: 7 diff --git a/PUBLISHING.md b/PUBLISHING.md index 486ee2e..145f8de 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,18 +1,33 @@ # Publish Ogiri -The `release` profile prepares the parent POM and both code artifacts, including sources, Javadoc, signatures and checksums. Normal `install`/CI never uploads to Central. Version is 0.1.0; no historical tag is changed. +Version is 0.1.0. The `release` profile signs the parent POM and both code artifacts, including sources and Javadoc. Normal `install` and CI never upload to Central. Historical tags are unchanged. -Run both database contract suites and the independent consumer, review the resolved dependency scan, and pin the exact commit before release. Configure Maven server ID `central` with your Sonatype user-token credentials outside the repository. Use GnuPG agent or `MAVEN_GPG_PASSPHRASE`; never put a private key/password in a POM, command argument or log. The namespace must be owned/verified in Central. +Run both database suites and the independent consumer, review the resolved dependency scan, and pin the exact commit before release. Supply Sonatype user-token credentials through Maven server ID `central` outside the repository. Use GnuPG agent or `MAVEN_GPG_PASSPHRASE`; never put a private key or password in a POM, command argument or log. Verify namespace ownership in Central. + +## Local rehearsal + +This deploys only to a local file repository. It explicitly calls Maven's native deploy goal, not the Central publisher injected into the `deploy` lifecycle. Use the disposable database settings from CONTRIBUTING.md when running tests. + +```sh +mvn -Prelease -Dgpg.skip=true verify \ + org.apache.maven.plugins:maven-deploy-plugin:3.1.4:deploy \ + -DaltDeploymentRepository="rehearsal::file://$PWD/target/release-repository" +(cd target/release-repository && zip -r ../ogiri-unsigned-bundle.zip com \ + -x '*/maven-metadata*') +``` + +The local ZIP must contain three POMs and six JARs at the Maven coordinate paths, with checksums. An unsigned bundle is not releasable. For local signed preparation omit `-Dgpg.skip=true`, select an empty local repository destination and sign through your configured agent. + +Central plugin 0.11.0's skipped-publishing execution did not produce a bundle in the tested environment. CI therefore verifies native local deployment and inspects actual ZIP entries instead of treating a successful skipped execution as publication evidence. No placeholder Central credentials are needed for this path. + +## Upload and publish + +Only with explicit release authority and real external signing/namespace/credential configuration: ```sh -# Local unsigned packaging rehearsal; this does NOT prove release signatures or Central acceptance. -mvn -Prelease -Dgpg.skip=true deploy -# Publisher's local signed bundle. Default central.skipPublishing=true prevents upload. -mvn -Prelease deploy -# Only with explicit release authority: upload for Central validation, but do not auto-publish. mvn -Prelease -Dcentral.skipPublishing=false deploy ``` -The last command uploads and waits for validation. `autoPublish=false` leaves final publication to the Central Portal. All three POM coordinates (parent, core, starter) must be included. After publication verify each POM/JAR resolves from Central with a fresh Maven repository and rerun the consumer. Do not announce publication merely because `install` or bundle generation passed. +The profile defaults to `central.skipPublishing=true` as an upload guard. The explicit command above enables upload for validation; `autoPublish=false` leaves final publication to the Central Portal. Include all three coordinates: parent, core and starter. After publication, verify each POM/JAR from a fresh Maven repository and rerun the consumer. Never announce publication merely because installation or packaging passed. -Central's official plugin supports `skipPublishing` for bundle-only operation and manual publication after validation. See [Sonatype Maven publishing](https://central.sonatype.org/publish/publish-portal-maven/) and [Maven GPG signing](https://maven.apache.org/plugins/maven-gpg-plugin/sign-mojo.html). Credentials/signatures/namespace ownership are publisher-only verification, not inferred from this repository. +See [Sonatype Maven publishing](https://central.sonatype.org/publish/publish-portal-maven/) and [Maven GPG signing](https://maven.apache.org/plugins/maven-gpg-plugin/sign-mojo.html). Real signing, credentials, namespace ownership and Central acceptance remain publisher-side verification. diff --git a/README.md b/README.md index b176e94..8aee46b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ SecurityFilterChain security(HttpSecurity http, OgiriSecurity ogiri) throws Exce Retain your existing request matchers, other authentication methods, error-dispatch handling and CSRF policy. Add the sign-in permit/exemption only when you use the built-in login. The helper is reusable across multiple chains; it is not a mutable singleton configurer. `ogiri.endpoints-enabled=false` removes the controller, and `ogiri.enabled=false` disables all Ogiri auto-configuration. Business authorization remains yours. Built-in management accepts an Ogiri session, not an unrelated Basic/JWT principal. +## Runtime patch level + +The tested Boot 4.1.1 consumer pins `tomcat.version=11.0.25` for CVE-2026-65905, CVE-2026-65182 and CVE-2026-68525. Your application's dependency management takes precedence over transitive versions: keep embedded Tomcat at 11.0.25 or a later compatible patched release. Remove this temporary override after upgrading to a Boot BOM that supplies the fixes. CI scans both resolved runtime graphs. + ## Database ownership Copy `META-INF/ogiri/schema-postgresql.sql` or `META-INF/ogiri/schema-mysql.sql` from the core JAR into an application-owned migration. Ogiri never reserves a Flyway version, runs DDL at startup, or modifies application tables. Spring Boot SQL initialization may be used explicitly in disposable development databases. Production migrations are application-owned. From a895443cb360108946beefbb024a02cff96b4f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAwa=E1=B9=A3=C3=A8y=C3=AD?= <52331341+mosobande@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:49:46 +0000 Subject: [PATCH 11/13] feat: add opt-in bounded-age session caching with Spring Cache --- README.md | 42 ++++- SECURITY.md | 10 ++ examples/spring-app/pom.xml | 4 + .../example/ogiri/CachedConsumerTest.java | 24 +++ .../ogiri/spring/OgiriAutoConfiguration.java | 25 ++- .../ogiri/spring/OgiriCacheProperties.java | 22 +++ .../spring/OgiriAutoConfigurationTest.java | 26 ++++ .../OgiriOpaqueTokenIntrospectorTest.java | 7 +- ogiri/pom.xml | 1 + .../com/quantipixels/ogiri/JdbcSessions.java | 35 ++++- .../java/com/quantipixels/ogiri/Session.java | 2 +- .../com/quantipixels/ogiri/SessionCache.java | 81 ++++++++++ .../java/com/quantipixels/ogiri/Subject.java | 2 +- .../quantipixels/ogiri/JdbcSessionsTest.java | 52 +++++++ .../quantipixels/ogiri/SessionCacheTest.java | 144 ++++++++++++++++++ 15 files changed, 464 insertions(+), 13 deletions(-) create mode 100644 examples/spring-app/src/test/java/example/ogiri/CachedConsumerTest.java create mode 100644 ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriCacheProperties.java create mode 100644 ogiri/src/main/java/com/quantipixels/ogiri/SessionCache.java create mode 100644 ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java diff --git a/README.md b/README.md index 8aee46b..8f2e688 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,50 @@ Use the authoritative primary and a normal underlying pool, not a transaction-aw The starter reuses the application transaction manager, including Spring JPA. With the core alone, pass the manager for the supplied DataSource to `new JdbcSessions(dataSource, policy, transactionManager)`. The two-argument constructor creates a JDBC manager and is intended for JDBC-only transaction contexts. Multiple data sources or managers require an explicitly selected `JdbcSessions` bean; do not select an unrelated manager. -Each authentication performs one indexed session read, no writes and no positive cache, followed by the current account lookup in the Spring adapter. Malformed credentials fail before query execution. Five-second SQL timeouts do not replace connection, socket or HTTP timeouts. Configure those through your pool/server. JDBC driver timeout units differ: PostgreSQL `socketTimeout` uses seconds; MySQL uses milliseconds. Do not share that numeric setting between drivers. Authentication expiry does not depend on cleanup. +With caching disabled (the default), each authentication performs one indexed session read and no writes, followed by the current account lookup in the Spring adapter. Malformed credentials fail before query execution. Five-second SQL timeouts do not replace connection, socket or HTTP timeouts. Configure those through your pool/server. JDBC driver timeout units differ: PostgreSQL `socketTimeout` uses seconds; MySQL uses milliseconds. Do not share that numeric setting between drivers. Authentication expiry does not depend on cleanup. Schedule `JdbcSessions.cleanup(batchSize)` in your existing jobs. It locks a bounded ID page with `SKIP LOCKED` and deletes it in the same transaction; concurrent workers need no leader lease. Limit job runtime and stop when fewer than a page is returned. Core-only callers can depend on `ogiri` and construct `JdbcSessions(dataSource, policy)` without Boot. +## Optional session caching + +Caching is **disabled by default**. Enable it only when your application accepts delayed revocation of cached sessions. Ogiri caches successful session lookups, not passwords, account status, authorities, principals or invalid tokens. The account adapter still runs on every request. A session-cache hit can authenticate during a database outage until its validation age or session expiry is reached; it cannot bypass an account-provider failure. + +Reuse your application's Spring `CacheManager` and a dedicated region: + +```yaml +ogiri: + cache: + enabled: true + name: my-app.ogiri.sessions + max-age: 5s +``` + +`max-age` defaults to `5s` and accepts `1ms` through `1m`. It bounds the age of the SQL validation, not time since the latest cache hit. Each hit checks both validation age and absolute session expiry. Ogiri measures age before the database read, so a late concurrent fill does not restart the window. Keep application and database clocks synchronized; the bound is subject to clock skew. Nodes with a shorter configured maximum age enforce that shorter age when reading shared entries. + +Enabling caching without an available `CacheManager` or the named region fails startup. A supplied `JdbcSessions` bean takes precedence and must be configured explicitly. Ogiri never enables caching for the rest of your application, installs a provider, or changes credential erasure. Multiple managers need a primary choice or an explicitly configured `JdbcSessions` bean. + +For **Caffeine**, add `spring-boot-starter-cache` and `com.github.ben-manes.caffeine:caffeine` in your application, enable Spring caching in a configuration class with `@EnableCaching`, and configure Boot: + +```yaml +spring: + cache: + type: caffeine + cache-names: my-app.ogiri.sessions + caffeine: + spec: maximumSize=10000,expireAfterWrite=5s +``` + +An explicitly declared `CacheManager` works without `@EnableCaching` because Ogiri uses the programmatic Spring Cache API. Other Spring Cache providers can be supplied through the same contract; configure their physical expiry, size limits and serialization. The payload supports Java serialization. Caffeine and Spring's store-by-value concurrent-map provider are exercised by the tests; a specific Redis deployment or custom serializer is not thereby certified. Use a dedicated region for each session database, restrict cache access as authentication authority, and clear that region on library upgrades. No raw bearer token is used as a key or value; keys contain a SHA-256 digest. + +Successful `revoke` and `revokeAll` evict the affected keys **after the SQL transaction commits**. Cache failures fall back to the database for reads and do not undo committed revocations. Do not depend on eviction for immediate cross-node revocation: local caches do not broadcast, shared caches can have in-flight fills, and eviction can fail. A cached session may remain accepted until its maximum age or expiry. Direct SQL changes and writers without the same cache have the same bounded-staleness limitation. Keep caching disabled for strict revocation, or independently require fresh authorization for sensitive operations. + +Core-only integration uses the same implementation: + +```java +var cache = new SessionCache(cacheManager.getCache("my-app.ogiri.sessions"), Duration.ofSeconds(5)); +var sessions = new JdbcSessions(dataSource, SessionPolicy.defaults(), transactionManager, cache); +``` + ## Deliberate limits No refresh/rotation protocol, cookie transport, registration, recovery orchestration or MFA is invented. Ordinary browser HttpSession applications should consider Spring Session JDBC; federated OAuth/OIDC should use an identity provider. These tools are complementary, not reimplemented here. See [SECURITY.md](SECURITY.md) for recovery coordination and token-lifetime trade-offs, and [PUBLISHING.md](PUBLISHING.md) for the opt-in Central release path. diff --git a/SECURITY.md b/SECURITY.md index 65b0e1b..0a19266 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,3 +35,13 @@ No authentication-positive cache exists. Storage/directory outages fail closed a Tests use disposable databases and destructive fixture setup. Functional tests, selected mutation probes, CodeQL, resolved-dependency scanning and local benchmarks cover different boundaries. No one signal proves absence of vulnerabilities, every possible race, or production capacity. Human review is still appropriate before production adoption. The starter uses the host transaction manager to suspend and resume JDBC or JPA state correctly. Direct core users with JPA must pass the corresponding manager. Session transactions remain independent; they do not make password reset and concurrent sign-in atomic. + +## Opt-in cache consistency and trust + +`ogiri.cache.enabled=true` explicitly changes session revocation from authoritative per-request validation to bounded-age validation. The default maximum age is five seconds; accepted values are one millisecond through one minute. Every hit checks the original validation age and absolute session expiry. Hits do not renew age. Late fills retain their original validation timestamp. Accurate bounds require synchronized application/database clocks; detected backward movement before a validation timestamp forces a miss. + +The database remains durable session authority, but a cache hit is sufficient for the session portion of authentication. Protect cache writes, network access and deserialization accordingly. Use a dedicated region per session database; do not share it with unrelated applications or allow untrusted values or type metadata. Only token digests and serializable session metadata enter the region. Passwords, account flags, authorities, principals, raw tokens, misses and backend failures are not cached by Ogiri. Account status and permissions are still resolved for each request, with the freshness provided by your account adapter. + +Revocation evicts affected entries after a successful independent database commit. Eviction is best effort, not a distributed security protocol. A concurrent load can refill after eviction; another JVM's local cache is unaffected; a cache outage can prevent eviction. All such entries remain limited by their original age and session expiry. Direct SQL revocation and unconfigured writers may likewise remain invisible to cached readers until that deadline. Provider TTL is a memory-management policy and does not replace Ogiri's hit-time checks. Configure bounded provider eviction even though expired entries cannot authenticate. + +During a database outage an already cached live session can be accepted for the remaining window, provided account checks succeed. A cache miss/read failure uses authoritative storage; a database failure is never converted to a successful new cache entry. An eviction failure does not report a committed database revocation as rolled back. Keep caching disabled when your security policy requires immediate revocation or unconditional database availability on every request. diff --git a/examples/spring-app/pom.xml b/examples/spring-app/pom.xml index 9d32aa0..dd2def5 100644 --- a/examples/spring-app/pom.xml +++ b/examples/spring-app/pom.xml @@ -8,9 +8,13 @@ org.postgresqlpostgresqlruntime org.springframework.bootspring-boot-starter-testtest org.springframework.bootspring-boot-starter-data-jpatest + org.springframework.bootspring-boot-starter-cachetest + com.github.ben-manes.caffeinecaffeinetest mysql com.mysqlmysql-connector-jruntime + org.springframework.bootspring-boot-starter-cachetest + com.github.ben-manes.caffeinecaffeinetest org.springframework.bootspring-boot-maven-plugin diff --git a/examples/spring-app/src/test/java/example/ogiri/CachedConsumerTest.java b/examples/spring-app/src/test/java/example/ogiri/CachedConsumerTest.java new file mode 100644 index 0000000..96a3d6d --- /dev/null +++ b/examples/spring-app/src/test/java/example/ogiri/CachedConsumerTest.java @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +package example.ogiri; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Import; + +/** Rerun the installed-artifact HTTP contract with a real, Boot-managed Caffeine provider. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.datasource.url=${OGIRI_TEST_JDBC_URL}", + "spring.datasource.username=${OGIRI_TEST_JDBC_USER}", + "spring.datasource.password=${OGIRI_TEST_JDBC_PASSWORD}", + "spring.datasource.hikari.maximum-pool-size=1", "demo.password=test-password", + "ogiri.cache.enabled=true", "ogiri.cache.max-age=5s", "ogiri.cache.name=consumer.sessions", + "spring.cache.type=caffeine", "spring.cache.cache-names=consumer.sessions", + "spring.cache.caffeine.spec=maximumSize=100,expireAfterWrite=5s" +}) +@Import(CachedConsumerTest.Caching.class) +class CachedConsumerTest extends ConsumerTest { + @TestConfiguration(proxyBeanMethods = false) + @EnableCaching + static class Caching {} +} diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java index b2e33d8..42b0bab 100644 --- a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriAutoConfiguration.java @@ -4,6 +4,8 @@ import com.quantipixels.ogiri.JdbcSessions; +import com.quantipixels.ogiri.SessionCache; +import org.springframework.cache.CacheManager; import com.quantipixels.ogiri.SessionPolicy; import com.quantipixels.ogiri.Subject; import javax.sql.DataSource; @@ -25,13 +27,14 @@ @AutoConfiguration( afterName = {"org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration", - "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration"}, + "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration", + "org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration"}, before = {org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration.class, org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration.class, org.springframework.boot.security.oauth2.server.resource.autoconfigure.web.OAuth2ResourceServerWebSecurityAutoConfiguration.class}) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @ConditionalOnProperty(prefix = "ogiri", name = "enabled", havingValue = "true", matchIfMissing = true) -@EnableConfigurationProperties(OgiriProperties.class) +@EnableConfigurationProperties({OgiriProperties.class, OgiriCacheProperties.class}) @EnableWebSecurity public final class OgiriAutoConfiguration { @Bean @ConditionalOnMissingBean @@ -40,10 +43,24 @@ SessionPolicy ogiriPolicy(OgiriProperties properties) { } @Bean @ConditionalOnMissingBean - JdbcSessions ogiriSessions(DataSource source, SessionPolicy policy, + JdbcSessions ogiriSessions(DataSource source, SessionPolicy policy, OgiriCacheProperties cacheProperties, + org.springframework.beans.factory.ObjectProvider caches, org.springframework.beans.factory.ObjectProvider managers) { var manager = managers.getIfAvailable(); - return manager == null ? new JdbcSessions(source, policy) : new JdbcSessions(source, policy, manager); + if (manager == null) { + var jdbcManager = new org.springframework.jdbc.support.JdbcTransactionManager(source); + jdbcManager.setRollbackOnCommitFailure(true); + manager = jdbcManager; + } + SessionCache cache = null; + if (cacheProperties.enabled()) { + CacheManager cacheManager = caches.getIfAvailable(); + if (cacheManager == null) throw new IllegalStateException("ogiri.cache.enabled requires an application CacheManager"); + var region = cacheManager.getCache(cacheProperties.name()); + if (region == null) throw new IllegalStateException("Configure the dedicated cache named by ogiri.cache.name"); + cache = new SessionCache(region, cacheProperties.maxAge()); + } + return new JdbcSessions(source, policy, manager, cache); } @Bean @ConditionalOnMissingBean diff --git a/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriCacheProperties.java b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriCacheProperties.java new file mode 100644 index 0000000..055c7ae --- /dev/null +++ b/ogiri-spring-boot-starter/src/main/java/com/quantipixels/ogiri/spring/OgiriCacheProperties.java @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri.spring; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * @param enabled opt into bounded-staleness session caching using the application's CacheManager + * @param name dedicated cache region, shared only by instances using the same session database + * @param maxAge maximum age of a cached validation, never extended by cache hits + */ +@ConfigurationProperties("ogiri.cache") +public record OgiriCacheProperties(@DefaultValue("false") boolean enabled, + @DefaultValue("ogiri.sessions") String name, @DefaultValue("5s") Duration maxAge) { + public OgiriCacheProperties { + if (name == null || name.isBlank()) throw new IllegalArgumentException("ogiri.cache.name must not be blank"); + if (maxAge == null || maxAge.compareTo(Duration.ofMillis(1)) < 0 || maxAge.compareTo(Duration.ofMinutes(1)) > 0) + throw new IllegalArgumentException("ogiri.cache.max-age must be between 1ms and 1m"); + } +} diff --git a/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java index c65af22..74ba2dd 100644 --- a/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java +++ b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriAutoConfigurationTest.java @@ -44,4 +44,30 @@ class OgiriAutoConfigurationTest { @Test void invalidEndpointPathsFailAtBindingRatherThanOpenUnintendedRoutes() { runner.withPropertyValues("ogiri.base-path=/auth/**").run(context -> assertThat(context).hasFailed()); } + + @Test void cacheOptInRequiresAProviderAndAnExistingRegion() { + runner.withPropertyValues("ogiri.cache.enabled=true").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseMessage("ogiri.cache.enabled requires an application CacheManager"); + }); + runner.withPropertyValues("ogiri.cache.enabled=true", "ogiri.cache.name=not-configured") + .withBean(org.springframework.cache.CacheManager.class, () -> new org.springframework.cache.concurrent.ConcurrentMapCacheManager("only-this")) + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseMessage("Configure the dedicated cache named by ogiri.cache.name"); + }); + runner.withPropertyValues("ogiri.cache.max-age=0s").run(context -> assertThat(context).hasFailed()); + } + + @Test void disabledCacheDoesNotConsultAnExistingManagerAndCustomStorageWins() { + org.springframework.cache.CacheManager unwanted = new org.springframework.cache.concurrent.ConcurrentMapCacheManager() { + @Override public org.springframework.cache.Cache getCache(String name) { throw new AssertionError("Disabled cache consulted provider"); } + }; + runner.withBean(org.springframework.cache.CacheManager.class, () -> unwanted) + .run(context -> assertThat(context).hasNotFailed()); + var custom = new JdbcSessions(new DriverManagerDataSource(System.getenv("OGIRI_TEST_JDBC_URL"), + System.getenv("OGIRI_TEST_JDBC_USER"), System.getenv("OGIRI_TEST_JDBC_PASSWORD"))); + runner.withPropertyValues("ogiri.cache.enabled=true").withBean(JdbcSessions.class, () -> custom) + .run(context -> { assertThat(context).hasNotFailed(); assertThat(context.getBean(JdbcSessions.class)).isSameAs(custom); }); + } } diff --git a/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java index b8ccfb0..e4f9ae4 100644 --- a/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java +++ b/ogiri-spring-boot-starter/src/test/java/com/quantipixels/ogiri/spring/OgiriOpaqueTokenIntrospectorTest.java @@ -53,7 +53,12 @@ class OgiriOpaqueTokenIntrospectorTest { assertFalse(principal.getAttributes().containsKey("token_hash")); } - @Test void disabledLockedExpiredAndMissingAccountsFailClosedWithoutCachingState() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void disabledLockedExpiredAndMissingAccountsFailClosedWithoutCachingState(boolean cached) { + if (cached) sessions = new JdbcSessions(dataSource, SessionPolicy.defaults(), + new org.springframework.jdbc.support.JdbcTransactionManager(dataSource), + new SessionCache(new org.springframework.cache.concurrent.ConcurrentMapCache("accounts-test"), java.time.Duration.ofSeconds(5))); var token = sessions.issue(OWNER, "phone").token(); var current = new AtomicReference(User.withUsername("login").password("unused").roles("USER").build()); var adapter = new OgiriOpaqueTokenIntrospector(sessions, subject -> current.get()); diff --git a/ogiri/pom.xml b/ogiri/pom.xml index 6b663fe..1653021 100644 --- a/ogiri/pom.xml +++ b/ogiri/pom.xml @@ -5,6 +5,7 @@ Ogiri Spring JDBC sessions org.springframeworkspring-jdbc + org.springframeworkspring-context com.mysqlmysql-connector-jtest com.zaxxerHikariCPtest org.junit.jupiterjunit-jupitertest diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java b/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java index 6047f49..c9dc476 100644 --- a/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java +++ b/ogiri/src/main/java/com/quantipixels/ogiri/JdbcSessions.java @@ -6,6 +6,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,6 +38,7 @@ public final class JdbcSessions { private final TransactionTemplate reads; private final Database database; private final SessionPolicy policy; + private final SessionCache cache; public JdbcSessions(DataSource source) { this(source, SessionPolicy.defaults()); } @@ -46,6 +48,12 @@ public JdbcSessions(DataSource source, SessionPolicy policy) { /** Use the application's transaction manager for this DataSource, including JPA-backed hosts. */ public JdbcSessions(DataSource source, SessionPolicy policy, PlatformTransactionManager manager) { + this(source, policy, manager, null); + } + + /** Opt into bounded-staleness lookups; a null cache preserves authoritative reads. */ + public JdbcSessions(DataSource source, SessionPolicy policy, PlatformTransactionManager manager, SessionCache cache) { + this.cache = cache; this.policy = Objects.requireNonNull(policy, "policy"); Objects.requireNonNull(source, "source"); if (source instanceof org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy) @@ -91,12 +99,14 @@ public IssuedSession issue(Subject subject, String client) { }); } - /** One authoritative indexed read, with no activity writes or positive-result cache. */ + /** One indexed read by default; configured cache hits may use a bounded-age session snapshot. */ public Optional authenticate(String token) { byte[] digest = Tokens.digest(token); if (digest == null) return Optional.empty(); - return read(() -> jdbc.query("SELECT " + COLUMNS + " FROM ogiri_sessions WHERE token_hash = ? AND expires_at > " + database.now, + Supplier> authoritative = () -> read(() -> jdbc.query( + "SELECT " + COLUMNS + " FROM ogiri_sessions WHERE token_hash = ? AND expires_at > " + database.now, JdbcSessions::row, digest).stream().findFirst()); + return cache == null ? authoritative.get() : cache.lookup(digest, authoritative); } /** List live session metadata for an application-authorized complete subject. */ @@ -110,18 +120,33 @@ public List list(Subject subject) { /** Delete one owned session; foreign or absent identifiers return false. */ public boolean revoke(Subject subject, UUID id) { Objects.requireNonNull(subject, "subject"); Objects.requireNonNull(id, "id"); - return write(() -> jdbc.update("DELETE FROM ogiri_sessions WHERE " + OWNER + " AND id = ?", - subject.realm(), subject.tenantId(), subject.subjectId(), id.toString()) == 1); + List hashes = new ArrayList<>(); + boolean removed = write(() -> { + if (cache != null) hashes.addAll(jdbc.queryForList( + "SELECT token_hash FROM ogiri_sessions WHERE " + OWNER + " AND id = ?", byte[].class, + subject.realm(), subject.tenantId(), subject.subjectId(), id.toString())); + return jdbc.update("DELETE FROM ogiri_sessions WHERE " + OWNER + " AND id = ?", + subject.realm(), subject.tenantId(), subject.subjectId(), id.toString()) == 1; + }); + if (cache != null) hashes.forEach(cache::evict); // Only after the independent commit succeeded. + return removed; } /** Serialize with issuance and revoke existing sessions; this does not ban future login. */ public int revokeAll(Subject subject) { Objects.requireNonNull(subject, "subject"); - return write(() -> { + List hashes = new ArrayList<>(); + int removed = write(() -> { lock(subject); + // Expired entries cannot authenticate, so avoid collecting historical hashes. + if (cache != null) hashes.addAll(jdbc.queryForList( + "SELECT token_hash FROM ogiri_sessions WHERE " + OWNER + " AND expires_at > " + database.now, + byte[].class, subject.realm(), subject.tenantId(), subject.subjectId())); return jdbc.update("DELETE FROM ogiri_sessions WHERE " + OWNER, subject.realm(), subject.tenantId(), subject.subjectId()); }); + if (cache != null) hashes.forEach(cache::evict); + return removed; } /** Bounded cleanup, safe with concurrent workers. Expiry enforcement never waits for cleanup. */ diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Session.java b/ogiri/src/main/java/com/quantipixels/ogiri/Session.java index 05b6c69..27770bf 100644 --- a/ogiri/src/main/java/com/quantipixels/ogiri/Session.java +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Session.java @@ -6,4 +6,4 @@ import java.util.UUID; /** Public session metadata. The identifier is for management; it is not an authentication credential. */ -public record Session(UUID id, Subject subject, String client, Instant createdAt, Instant expiresAt) {} +public record Session(UUID id, Subject subject, String client, Instant createdAt, Instant expiresAt) implements java.io.Serializable {} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/SessionCache.java b/ogiri/src/main/java/com/quantipixels/ogiri/SessionCache.java new file mode 100644 index 0000000..4c88080 --- /dev/null +++ b/ogiri/src/main/java/com/quantipixels/ogiri/SessionCache.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Quanti Pixels +package com.quantipixels.ogiri; + +import java.io.Serializable; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.cache.Cache; + +/** + * Opt-in, bounded-staleness session lookup cache over a dedicated Spring Cache region. + * Does not cache accounts, authorities, credentials or misses. Hits never renew the validation age. + * Configure provider eviction to bound memory and keep participating clocks synchronized. + * Revocation eviction is best effort: local providers, concurrent fills and provider failures + * can retain a revoked session until maxAge. Leave caching disabled for immediate revocation. + * Cache storage is authentication authority: restrict writes, access and deserialization. + */ +public final class SessionCache { + private static final Log LOG = LogFactory.getLog(SessionCache.class); + private final Cache cache; + private final Duration maxAge; + private final Clock clock; + + public SessionCache(Cache cache, Duration maxAge) { this(cache, maxAge, Clock.systemUTC()); } + + SessionCache(Cache cache, Duration maxAge, Clock clock) { + this.cache = Objects.requireNonNull(cache, "cache"); + this.maxAge = Objects.requireNonNull(maxAge, "maxAge"); + this.clock = Objects.requireNonNull(clock, "clock"); + if (maxAge.compareTo(Duration.ofMillis(1)) < 0 || maxAge.compareTo(Duration.ofMinutes(1)) > 0) + throw new IllegalArgumentException("Session cache maxAge must be between 1ms and 1m"); + } + + Optional lookup(byte[] digest, Supplier> authoritative) { + String key = key(digest); + try { + Entry entry = cache.get(key, Entry.class); + if (entry != null && usable(entry, clock.instant())) return Optional.of(entry.session()); + } catch (RuntimeException unavailable) { + // No provider exception text, keys or credentials in logs. + LOG.debug("Session cache read failed; using authoritative storage"); + } + Instant started = clock.instant(); + Optional result = authoritative.get(); // A failed lookup is never cached. + result.ifPresent(session -> { + Entry entry = new Entry(session, started); + // Anchor age before I/O: a slow fill must not create a fresh staleness window. + if (usable(entry, clock.instant())) { + try { cache.put(key, entry); } + catch (RuntimeException unavailable) { LOG.debug("Session cache write failed; retaining database result"); } + } + }); + return result; + } + + void evict(byte[] digest) { + try { cache.evictIfPresent(key(digest)); } + catch (RuntimeException unavailable) { LOG.debug("Session cache eviction failed; database revocation remains committed"); } + } + + private boolean usable(Entry entry, Instant now) { + return entry.validatedAt() != null && entry.session() != null && entry.session().expiresAt() != null + && !now.isBefore(entry.validatedAt()) + && Duration.between(entry.validatedAt(), now).compareTo(maxAge) < 0 + && now.isBefore(entry.session().expiresAt()); + } + + private static String key(byte[] digest) { return "ogiri:session:v1:" + HexFormat.of().formatHex(digest); } + + // Provider payload, not a public persistence format. Clear this dedicated region on upgrades. + private record Entry(Session session, Instant validatedAt) implements Serializable { + private static final long serialVersionUID = 1L; + } +} diff --git a/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java b/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java index 5fa973f..cefe872 100644 --- a/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java +++ b/ogiri/src/main/java/com/quantipixels/ogiri/Subject.java @@ -5,7 +5,7 @@ import java.util.Objects; /** Stable account identity. An empty tenantId denotes a non-tenanted realm, never a wildcard. */ -public record Subject(String realm, String tenantId, String subjectId) { +public record Subject(String realm, String tenantId, String subjectId) implements java.io.Serializable { public Subject { Objects.requireNonNull(realm, "realm"); Objects.requireNonNull(tenantId, "tenantId"); diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java index 107b180..65b4c7d 100644 --- a/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java +++ b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java @@ -226,6 +226,58 @@ private static DriverManagerDataSource source() { assertThrows(IllegalArgumentException.class, () -> new Subject("users", "", String.valueOf((char) 0xD800))); } + + @Test void cachedReadersAvoidDatabaseIoAndSharedEvictionTargetsOnlyRevokedSessions() { + var manager = new org.springframework.cache.concurrent.ConcurrentMapCacheManager("shared"); + manager.setStoreByValue(true); + var region = java.util.Objects.requireNonNull(manager.getCache("shared")); + var offline = new java.util.concurrent.atomic.AtomicBoolean(false); + var source = new DelegatingDataSource(dataSource) { + @Override public Connection getConnection() throws SQLException { + if (offline.get()) throw new SQLException("controlled database outage"); + return super.getConnection(); + } + }; + var reader = new JdbcSessions(source, SessionPolicy.defaults(), + new org.springframework.jdbc.support.JdbcTransactionManager(source), new SessionCache(region, Duration.ofMinutes(1))); + var writer = new JdbcSessions(dataSource, SessionPolicy.defaults(), + new org.springframework.jdbc.support.JdbcTransactionManager(dataSource), new SessionCache(region, Duration.ofMinutes(1))); + var first = writer.issue(OWNER, "first"); + var second = writer.issue(OWNER, "second"); + var foreign = writer.issue(new Subject("users", "other-tenant", "user-42"), "foreign"); + for (var issued : List.of(first, second, foreign)) assertEquals(issued.session(), reader.authenticate(issued.token()).orElseThrow()); + offline.set(true); + assertEquals(first.session(), reader.authenticate(first.token()).orElseThrow(), "Hit must avoid database I/O"); + assertTrue(reader.authenticate("malformed").isEmpty()); + assertThrows(SessionStoreException.class, () -> reader.authenticate("og1_" + "A".repeat(43))); + assertFalse(writer.revoke(foreign.session().subject(), first.session().id())); + assertEquals(first.session(), reader.authenticate(first.token()).orElseThrow()); + assertTrue(writer.revoke(OWNER, first.session().id())); + assertThrows(SessionStoreException.class, () -> reader.authenticate(first.token()), "Revocation evicts across readers sharing the region"); + assertEquals(second.session(), reader.authenticate(second.token()).orElseThrow()); + assertEquals(1, writer.revokeAll(OWNER)); + assertThrows(SessionStoreException.class, () -> reader.authenticate(second.token())); + assertEquals(foreign.session(), reader.authenticate(foreign.token()).orElseThrow(), "Other owners are not evicted"); + offline.set(false); + assertTrue(reader.authenticate(first.token()).isEmpty()); + assertTrue(reader.authenticate(second.token()).isEmpty()); + } + + @Test void cacheEvictionOccursAfterCommitAndItsFailureCannotUndoRevocation() { + var issued = sessions.issue(OWNER, "phone"); + var region = new org.springframework.cache.concurrent.ConcurrentMapCache("failure") { + @Override public boolean evictIfPresent(Object key) { + assertTrue(sessions.authenticate(issued.token()).isEmpty(), "Another connection must see the committed revocation"); + throw new IllegalStateException("controlled cache eviction failure"); + } + }; + var cached = new JdbcSessions(dataSource, SessionPolicy.defaults(), + new org.springframework.jdbc.support.JdbcTransactionManager(dataSource), new SessionCache(region, Duration.ofSeconds(5))); + assertEquals(issued.session(), cached.authenticate(issued.token()).orElseThrow()); + assertTrue(cached.revoke(OWNER, issued.session().id())); + assertTrue(sessions.authenticate(issued.token()).isEmpty()); + } + private static void sql(String sql) throws SQLException { try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { statement.execute(sql); } } diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java b/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java new file mode 100644 index 0000000..7089824 --- /dev/null +++ b/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.quantipixels.ogiri; + +import static org.junit.jupiter.api.Assertions.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import org.junit.jupiter.api.Test; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; + +class SessionCacheTest { + private static final Instant START = Instant.parse("2030-01-01T00:00:00Z"); + private static final byte[] KEY = new byte[32]; + private static Session session(Instant expiry) { + return new Session(UUID.randomUUID(), new Subject("users", "tenant", "42"), "phone", START, expiry); + } + + @Test void fixedAgeAndHardExpiryAreEnforcedEvenWhenProviderNeverExpiresValues() { + var clock = new MutableClock(START); + var region = new ConcurrentMapCache("sessions"); + var cache = new SessionCache(region, Duration.ofSeconds(5), clock); + Session live = session(START.plusSeconds(100)); + var calls = new AtomicInteger(); + java.util.function.Supplier> loader = () -> { + calls.incrementAndGet(); return Optional.of(live); + }; + assertEquals(live, cache.lookup(KEY, loader).orElseThrow()); + clock.now = START.plusSeconds(4); + assertEquals(live, cache.lookup(KEY, loader).orElseThrow()); + assertEquals(1, calls.get()); + clock.now = START.plusSeconds(5); + assertTrue(cache.lookup(KEY, Optional::empty).isEmpty(), "Hits must not slide the original validation age"); + + region.clear(); clock.now = START; + Session expiring = session(START.plusSeconds(1)); + cache.lookup(KEY, () -> Optional.of(expiring)); + clock.now = expiring.expiresAt(); + assertTrue(cache.lookup(KEY, Optional::empty).isEmpty(), "Session expiry wins over cache age"); + clock.now = START.minusSeconds(1); + assertTrue(cache.lookup(KEY, Optional::empty).isEmpty(), "Clock rollback must not accept a future validation"); + } + + @Test void slowProviderReadsAndStricterReadersCannotExtendTheDeadline() { + var clock = new MutableClock(START); + var region = new ConcurrentMapCache("sessions") { + boolean delay; + @Override public T get(Object key, Class type) { + T value = super.get(key, type); + if (delay) clock.now = START.plusSeconds(5); + delay = true; + return value; + } + }; + var cache = new SessionCache(region, Duration.ofSeconds(5), clock); + cache.lookup(KEY, () -> Optional.of(session(START.plusSeconds(100)))); + assertTrue(cache.lookup(KEY, Optional::empty).isEmpty(), "Validate age after provider I/O, not before it"); + var ordinary = new ConcurrentMapCache("shared"); clock.now = START; + new SessionCache(ordinary, Duration.ofSeconds(30), clock) + .lookup(KEY, () -> Optional.of(session(START.plusSeconds(100)))); + clock.now = START.plusSeconds(5); + assertTrue(new SessionCache(ordinary, Duration.ofSeconds(2), clock).lookup(KEY, Optional::empty).isEmpty()); + } + + @Test void fillAfterConcurrentRevocationRetainsOnlyTheOriginalBoundedWindow() throws Exception { + var clock = new MutableClock(START); + var cache = new SessionCache(new ConcurrentMapCache("race"), Duration.ofSeconds(5), clock); + var read = new CountDownLatch(1); + var resume = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try { + Session old = session(START.plusSeconds(100)); + var pending = executor.submit(() -> cache.lookup(KEY, () -> { + read.countDown(); + try { assertTrue(resume.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + return Optional.of(old); + })); + assertTrue(read.await(5, TimeUnit.SECONDS)); + cache.evict(KEY); // The authoritative revocation has committed while a prior read is in flight. + clock.now = START.plusSeconds(4); + resume.countDown(); + assertEquals(old, pending.get(5, TimeUnit.SECONDS).orElseThrow()); + assertEquals(old, cache.lookup(KEY, Optional::empty).orElseThrow(), "Bounded-staleness mode allows this race"); + clock.now = START.plusSeconds(5); + assertTrue(cache.lookup(KEY, Optional::empty).isEmpty(), "The late fill must not earn another five seconds"); + } finally { resume.countDown(); executor.shutdownNow(); assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); } + } + + @Test void failuresAndMissesAreNotCachedAndProviderFaultsUseTheAuthoritativeResult() { + var region = new ConcurrentMapCache("sessions"); + var cache = new SessionCache(region, Duration.ofSeconds(5), new MutableClock(START)); + assertTrue(cache.lookup(KEY, Optional::empty).isEmpty()); + assertTrue(region.getNativeCache().isEmpty()); + assertThrows(IllegalStateException.class, () -> cache.lookup(KEY, () -> { throw new IllegalStateException("database offline"); })); + assertTrue(region.getNativeCache().isEmpty()); + var unavailable = new ConcurrentMapCache("offline") { + @Override public T get(Object key, Class type) { throw new IllegalStateException("get offline"); } + @Override public void put(Object key, Object value) { throw new IllegalStateException("put offline"); } + @Override public boolean evictIfPresent(Object key) { throw new IllegalStateException("evict offline"); } + }; + var fallback = new SessionCache(unavailable, Duration.ofSeconds(5), new MutableClock(START)); + var expected = session(START.plusSeconds(100)); + assertEquals(expected, fallback.lookup(KEY, () -> Optional.of(expected)).orElseThrow()); + assertDoesNotThrow(() -> fallback.evict(KEY)); + assertThrows(IllegalStateException.class, () -> fallback.lookup(KEY, () -> { throw new IllegalStateException("database offline"); })); + } + + @Test void serializedCachePayloadRoundTripsWithoutStoringTheBearerCredential() { + var manager = new ConcurrentMapCacheManager("shared"); + manager.setStoreByValue(true); + var region = Objects.requireNonNull(manager.getCache("shared")); + var clock = new MutableClock(START); + var first = new SessionCache(region, Duration.ofSeconds(5), clock); + var second = new SessionCache(region, Duration.ofSeconds(5), clock); + var original = session(START.plusSeconds(100)); + String token = Tokens.generate(); + byte[] hash = Tokens.digest(token); + first.lookup(hash, () -> Optional.of(original)); + var copy = second.lookup(hash, () -> { throw new AssertionError("Cache did not round-trip"); }).orElseThrow(); + assertEquals(original, copy); + assertNotSame(original, copy); + var keys = ((ConcurrentMapCache) region).getNativeCache().keySet(); + assertEquals(Set.of("ogiri:session:v1:" + HexFormat.of().formatHex(hash)), keys); + assertFalse(keys.toString().contains(token)); + second.evict(hash); + assertTrue(first.lookup(hash, Optional::empty).isEmpty()); + } + + @Test void invalidCacheAgesAreRejectedRatherThanDisablingExpiry() { + var region = new ConcurrentMapCache("sessions"); + for (Duration invalid : List.of(Duration.ZERO, Duration.ofMillis(-1), Duration.ofMinutes(2))) + assertThrows(IllegalArgumentException.class, () -> new SessionCache(region, invalid)); + } + + private static final class MutableClock extends Clock { + volatile Instant now; + MutableClock(Instant now) { this.now = now; } + @Override public Instant instant() { return now; } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + } +} From 69f81e9afb652492c6f0ef6edece350b855dcb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAwa=E1=B9=A3=C3=A8y=C3=AD?= <52331341+mosobande@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:04:28 +0000 Subject: [PATCH 12/13] test: exercise serialized session cache values and clarify optional consistency --- SECURITY.md | 4 ++-- .../test/java/com/quantipixels/ogiri/JdbcSessionsTest.java | 1 + .../test/java/com/quantipixels/ogiri/SessionCacheTest.java | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 0a19266..6f71245 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,9 +24,9 @@ Use packaged schema templates through your own migrations. MySQL requires InnoDB Writes use Spring-managed independent transactions at READ_COMMITTED. Account admission and revoke-all take stable digest-keyed row locks. Lock digest collisions would add contention, not merge authorization, because SQL still checks all identity components. Lock rows must not be removed while writers can use them. They grow per identity with issued sessions; their retention avoids an unsafe lock-removal race. -Read operations suspend outer JDBC transactions and query the primary. Do not supply transaction-aware or lagging-replica-routing data sources. An existing outer transaction needs additional connection capacity. Spring handles suspension/resumption, rollback and connection-state restoration; a connection loss during commit can still make the outcome unknown. In that case no credential is returned, but an orphaned row may occupy capacity. Do not blindly retry issuance. +Database read operations suspend outer JDBC or JPA transactions and query the primary. Do not supply transaction-aware or lagging-replica-routing data sources. An existing outer transaction needs additional connection capacity. Spring handles suspension/resumption, rollback and connection-state restoration; a connection loss during commit can still make the outcome unknown. In that case no credential is returned, but an orphaned row may occupy capacity. Do not blindly retry issuance. -No authentication-positive cache exists. Storage/directory outages fail closed and remain distinct from invalid credentials. SQL timeout is five seconds; configure pool acquisition/socket timeouts, TLS and gateway limits separately. Cleanup skips locked expired rows and never determines whether an expired token is accepted. +With caching disabled (the default), every session validation queries authoritative storage. Storage/directory failures remain distinct from invalid credentials. Opt-in caching changes revocation and database-outage behavior as described below. SQL timeout is five seconds; configure pool acquisition/socket timeouts, TLS and gateway limits separately. Cleanup skips locked expired rows and never determines whether an expired token is accepted. ## Secret handling and proof diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java index 65b4c7d..b83cf4d 100644 --- a/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java +++ b/ogiri/src/test/java/com/quantipixels/ogiri/JdbcSessionsTest.java @@ -230,6 +230,7 @@ private static DriverManagerDataSource source() { @Test void cachedReadersAvoidDatabaseIoAndSharedEvictionTargetsOnlyRevokedSessions() { var manager = new org.springframework.cache.concurrent.ConcurrentMapCacheManager("shared"); manager.setStoreByValue(true); + manager.setBeanClassLoader(JdbcSessionsTest.class.getClassLoader()); var region = java.util.Objects.requireNonNull(manager.getCache("shared")); var offline = new java.util.concurrent.atomic.AtomicBoolean(false); var source = new DelegatingDataSource(dataSource) { diff --git a/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java b/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java index 7089824..8dfaa47 100644 --- a/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java +++ b/ogiri/src/test/java/com/quantipixels/ogiri/SessionCacheTest.java @@ -110,6 +110,7 @@ private static Session session(Instant expiry) { @Test void serializedCachePayloadRoundTripsWithoutStoringTheBearerCredential() { var manager = new ConcurrentMapCacheManager("shared"); manager.setStoreByValue(true); + manager.setBeanClassLoader(SessionCacheTest.class.getClassLoader()); var region = Objects.requireNonNull(manager.getCache("shared")); var clock = new MutableClock(START); var first = new SessionCache(region, Duration.ofSeconds(5), clock); From 5119a06f42fab49ed1b29fd38ddcebe33edad608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAwa=E1=B9=A3=C3=A8y=C3=AD?= <52331341+mosobande@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:08:14 +0100 Subject: [PATCH 13/13] ci: verify packaged opt-in cache configuration metadata --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a1e9ce..c1c24b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,8 @@ jobs: with ZipFile(f'ogiri-spring-boot-starter/target/ogiri-spring-boot-starter-{version}.jar') as jar: metadata=json.loads(jar.read('META-INF/spring-configuration-metadata.json')) names={p['name'] for p in metadata['properties']} - assert {'ogiri.enabled','ogiri.base-path','ogiri.endpoints-enabled','ogiri.lifetime','ogiri.maximum-sessions','ogiri.realm'} <= names + assert {'ogiri.enabled','ogiri.base-path','ogiri.endpoints-enabled','ogiri.lifetime','ogiri.maximum-sessions','ogiri.realm', + 'ogiri.cache.enabled','ogiri.cache.name','ogiri.cache.max-age'} <= names imports=jar.read('META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports').decode() assert 'com.quantipixels.ogiri.spring.OgiriAutoConfiguration' in imports Path('target').mkdir(exist_ok=True)