diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a80f63..72adfbc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,12 +17,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: 17 distribution: "temurin" @@ -33,7 +33,7 @@ jobs: - name: Upload build artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: build-artifacts path: "**/build/libs/*.jar" diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index fe989bc..ba415a8 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -29,17 +29,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node-version }} cache: "pnpm" @@ -55,12 +55,12 @@ jobs: run: pnpm vitest run --coverage - name: Upload coverage reports - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 if: matrix.node-version == 20 with: files: ./ogiri-client/coverage/lcov.info flags: typescript-client - fail_ci_if_error: false + fail_ci_if_error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..40bd0e5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + pull_request: + push: + branches: [ori] + schedule: + - cron: "17 3 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Initialize CodeQL + 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 diff --git a/.github/workflows/docs-dev.yml b/.github/workflows/docs-dev.yml index 122e637..cb30c77 100644 --- a/.github/workflows/docs-dev.yml +++ b/.github/workflows/docs-dev.yml @@ -21,11 +21,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" cache: "pip" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 236f56a..1f8b908 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,12 +17,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: 17 distribution: "temurin" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59670d0..3643493 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,165 +9,132 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +permissions: + contents: read + jobs: - release: + verify: runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - packages: write - + timeout-minutes: 30 steps: - - name: Checkout code - uses: actions/checkout@v6 + - name: Checkout immutable tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - fetch-depth: 0 - - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - - name: Update version file - run: | - git fetch origin ori - git checkout -B ori origin/ori - echo "${{ steps.version.outputs.VERSION }}" > .ogiri-version - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git add .ogiri-version - git commit -m "chore: update version to ${{ steps.version.outputs.VERSION }}" || true - git push origin ori + persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: 17 - distribution: "temurin" + distribution: temurin cache: gradle - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.version.outputs.VERSION }} - name: Release ${{ steps.version.outputs.VERSION }} - body: | - ## Release ${{ steps.version.outputs.VERSION }} - - ### Artifacts - Published to Maven Central: - - `com.quantipixels.ogiri:ogiri-core:${{ steps.version.outputs.VERSION }}` - - `com.quantipixels.ogiri:ogiri-jpa:${{ steps.version.outputs.VERSION }}` - - ### Installation - - **With JPA Support (Recommended):** - ```gradle - implementation 'com.quantipixels.ogiri:ogiri-jpa:${{ steps.version.outputs.VERSION }}' - ``` + - name: Verify source, tests, coverage, consumers, and dependencies + run: ./gradlew clean check dependencyCheckAnalyze --no-daemon - **Core Only:** - ```gradle - implementation 'com.quantipixels.ogiri:ogiri-core:${{ steps.version.outputs.VERSION }}' - ``` - - For details, see [Changelog](https://quantipixels.github.io/ogiri/changelog/) and [Documentation](https://quantipixels.github.io/ogiri/). - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Stage every publication locally + run: ./gradlew publishToMavenLocal --no-daemon publish: + needs: verify runs-on: ubuntu-latest timeout-minutes: 30 permissions: - contents: write - packages: write - + contents: read + outputs: + version: ${{ steps.version.outputs.version }} steps: - - name: Checkout code - uses: actions/checkout@v6 + - name: Checkout immutable tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - fetch-depth: 0 persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: 17 - distribution: "temurin" + distribution: temurin cache: gradle - - name: Extract version from tag + - name: Extract immutable tag version id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + shell: bash + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + - name: Import signing key + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} - - name: Publish to Maven Central + - name: Publish signed artifacts run: ./gradlew publish --no-daemon env: - RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} + 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: Verify published artifact + - name: Verify every published module resolves + shell: bash run: | - VERSION="${{ steps.version.outputs.VERSION }}" - URL="https://repo1.maven.org/maven2/com/quantipixels/ogiri/ogiri-core/${VERSION}/ogiri-core-${VERSION}.jar" - MAX_RETRIES=10 - RETRY_INTERVAL=60 - - echo "Verifying artifact availability at: $URL" - - for i in $(seq 1 $MAX_RETRIES); do - if curl -sfI "$URL" > /dev/null; then - echo "Artifact verified on Maven Central" - exit 0 - fi - - echo "Attempt $i/$MAX_RETRIES: Artifact not yet available. Retrying in ${RETRY_INTERVAL}s..." - sleep $RETRY_INTERVAL + 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::Artifact not available on Maven Central after $((MAX_RETRIES * RETRY_INTERVAL / 60)) minutes." + 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: - - uses: actions/checkout@v6 + - name: Checkout immutable tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - - uses: actions/setup-python@v6 + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - cache: "pip" + cache: pip - run: pip install -e . - - run: | + - 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" - - - name: Extract minor version - id: version - run: | - TAG="${GITHUB_REF#refs/tags/v}" - echo "MINOR=$(echo "$TAG" | grep -oE '^[0-9]+\.[0-9]+')" >> $GITHUB_OUTPUT - - - name: Deploy versioned docs - run: | - mike deploy --push --update-aliases "${{ steps.version.outputs.MINOR }}" latest + mike deploy --push --update-aliases "$minor" latest mike set-default --push latest diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index bd09724..9016eab 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -6,6 +6,12 @@ on: 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/**" @@ -25,19 +31,19 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: 17 distribution: "temurin" cache: gradle - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39b460c..2fc033c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [ori] +permissions: + contents: read + id-token: write + concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true @@ -17,12 +21,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: 17 distribution: "temurin" @@ -33,7 +37,7 @@ jobs: - name: Generate test report if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: test-results path: | @@ -44,12 +48,9 @@ jobs: - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 with: - files: | - ./ogiri-core/build/reports/jacoco/test/jacocoTestReport.xml - ./ogiri-jpa/build/reports/jacoco/test/jacocoTestReport.xml + files: ./ogiri-core/build/reports/jacoco/test/jacocoTestReport.xml,./ogiri-jpa/build/reports/jacoco/test/jacocoTestReport.xml flags: unittests fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + use_oidc: true diff --git a/.ogiri-version b/.ogiri-version index 94ff29c..fcdb2e1 100644 --- a/.ogiri-version +++ b/.ogiri-version @@ -1 +1 @@ -3.1.1 +4.0.0 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..ab743e4 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,49 @@ +# 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/README.md b/README.md index 762a66a..45bf1f4 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,104 @@ -# Γ’giri +# Ogiri -[![Test](https://github.com/quantipixels/ogiri/actions/workflows/test.yml/badge.svg)](https://github.com/quantipixels/ogiri/actions/workflows/test.yml) -[![Build](https://github.com/quantipixels/ogiri/actions/workflows/build.yml/badge.svg)](https://github.com/quantipixels/ogiri/actions/workflows/build.yml) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.quantipixels.ogiri/ogiri-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.quantipixels.ogiri/ogiri-core) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Java](https://img.shields.io/badge/java-17+-blue.svg)](https://www.oracle.com/java/technologies/javase/jdk17-archive.html) -[![Spring Boot](https://img.shields.io/badge/spring%20boot-3.5+-green.svg)](https://spring.io/projects/spring-boot) - -Reusable Spring Boot security components for token-based authentication with pluggable sub-tokens. - -**[πŸ“– Full Documentation](https://quantipixels.github.io/ogiri/)** | [Quickstart](https://quantipixels.github.io/ogiri/quickstart/) | [Migration Guide](https://quantipixels.github.io/ogiri/migration-guide/) +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. ## Features -- **Database-agnostic** - Works with JPA, MongoDB, Redis, or any custom persistence -- **Auto-configured** - Spring Boot auto-configuration with customization options -- **Token rotation** - Configurable rotation with batch request detection -- **Sub-tokens** - Pluggable sub-tokens for chat, device, API, etc. -- **Secure by default** - BCrypt hashing, timestamp validation, grant-based authorization - -## Installation +- 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 -### Server (Kotlin/Java) +Ogiri v4 supports Java 17, Spring Boot 3.5, and servlet applications. -> See the Maven Central badge above for the latest version. Replace `VERSION` in all snippets below. +## Modules -**With JPA Support (Recommended):** +| 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 | -```kotlin -// See badge above for latest version -implementation("com.quantipixels.ogiri:ogiri-jpa:VERSION") -``` +## Install -**With JDBC Support (no ORM):** +Use the BOM and select the adapters your application needs: ```kotlin -// See badge above for latest version -implementation("com.quantipixels.ogiri:ogiri-jdbc:VERSION") -``` +dependencies { + implementation(platform("com.quantipixels.ogiri:ogiri-bom:VERSION")) + implementation("com.quantipixels.ogiri:ogiri-jpa") -**Core Only (Custom Persistence):** + implementation("org.flywaydb:flyway-core") + runtimeOnly("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") -```kotlin -// See badge above for latest version -implementation("com.quantipixels.ogiri:ogiri-core:VERSION") + testImplementation("com.quantipixels.ogiri:ogiri-test") +} ``` -**Optional lookup caches:** +Add `com.quantipixels.ogiri:ogiri-redis` when distributed rate limiting is required. -```kotlin -// See badge above for latest version -implementation("com.quantipixels.ogiri:ogiri-caffeine:VERSION") // in-process -implementation("com.quantipixels.ogiri:ogiri-redis:VERSION") // distributed -``` +## Configure -**Maven (JPA):** +Ogiri is opt-in. Supply a `UserDetailsService` or `SubjectStatusChecker`, then configure a Base64-encoded key containing at least 32 random bytes: -```xml - - - com.quantipixels.ogiri - ogiri-jpa - VERSION - +```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 ``` -**Requirements:** Java 17+, Spring Boot 3.5+ +Generate key material outside source control, for example: -## Quick Start - -**1. Implement user directory:** - -Connect your user database by implementing `OgiriUserDirectory`. Note that `loadUserByUsername` is inherited from Spring Security's `UserDetailsService` and must throw `UsernameNotFoundException` if the user does not exist. - -```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("User not found: $username") - - override fun recordSuccessfulLogin(userId: Long) { userService.recordLogin(userId) } -} +```bash +openssl rand -base64 32 ``` -**2. Declare public routes:** +When the application defines a `SecurityFilterChain`, apply `OgiriHttpConfigurer` to that same chain so authentication and authorization remain together. -```kotlin -@Component -class MyRouteRegistry : OgiriRouteRegistry { - override fun routes() = listOf(OgiriRoute.post("/api/auth/**"), OgiriRoute.get("/api/health")) -} -``` - -**3. Extend `OgiriTokenService` with your token type:** +## Use ```kotlin -@Service -class MyTokenService( - repository: MyTokenRepository, // your concrete Spring Data repository - passwordEncoder: PasswordEncoder, - userDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - properties: OgiriConfigurationProperties, -) : OgiriTokenService( - repository, passwordEncoder, userDirectory, - identifierPolicy, subTokenRegistry, properties, -) { - override fun tokenFactory(...): MyToken = MyToken().apply { /* set fields */ } -} -``` +val subject = OgiriSessions.subject("users", "opaque-user-id", "tenant-a") +val client = OgiriSessions.client("browser-id", "Work laptop") -Optional extension points (`OgiriAuditHook`, `OgiriRateLimitHook`, `OgiriTokenLookupCache`) are wired automatically via setter injection when the corresponding beans are present β€” no constructor changes needed. - -**4. Issue tokens on login:** - -```kotlin -@PostMapping("/api/auth/login") -fun login(@RequestBody request: LoginRequest, response: HttpServletResponse) { - val user = authenticate(request.username, request.password) - response.appendAuthHeaders(tokenService.createNewAuthToken(user.id, "web")) -} +val issued = sessions.issue(subject, client) +val authenticated = sessions.authenticate(issued.credential.encoded(codec)) +sessions.revoke(authenticated) ``` -Done! Γ’giri auto-configures the security filter chain. - -See the [full Quickstart Guide](https://quantipixels.github.io/ogiri/quickstart/) for complete examples in both Kotlin and Java. +The optional endpoint starter provides sign-in, refresh, sign-out, current-session, session-listing, and revocation routes under the configured base path. ## Documentation -| Topic | Description | -| ----------------------------------------------------------------------------------------- | ------------------------------------------ | -| [Quickstart](https://quantipixels.github.io/ogiri/quickstart/) | 5-minute integration guide | -| [Interface Design](https://quantipixels.github.io/ogiri/core-concepts/interface-design/) | Architecture and design philosophy | -| [Configuration](https://quantipixels.github.io/ogiri/guides/configuration/) | Token rotation, cleanup, batch windows | -| [Database Integration](https://quantipixels.github.io/ogiri/guides/database-integration/) | JPA, JDBC, MongoDB, Redis examples | -| [Sub-tokens](https://quantipixels.github.io/ogiri/guides/sub-tokens/) | Device, chat, API tokens | -| [Authentication Flow](https://quantipixels.github.io/ogiri/guides/authentication-flow/) | Request lifecycle, headers | -| [Migration Guide](https://quantipixels.github.io/ogiri/guides/migration-guide/) | Upgrade guide (see docs for version notes) | -| [Sample Applications](https://github.com/quantipixels/ogiri/tree/main/sample) | Java and Kotlin examples | - -## Development - -**Requirements:** Java 17 (Java 25 is not supported β€” Kotlin compiler and Spotless/google-java-format incompatibilities). +- [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) -**Kotlin/Java:** +## Build ```bash -./gradlew build # Build and test all modules -./gradlew test # Run tests only -./gradlew :ogiri-core:test # Run core module tests only -./gradlew spotlessApply # Format code +./gradlew check ``` -**Git hooks (optional):** - -```bash -lefthook install -``` - -See [development guide](https://quantipixels.github.io/ogiri/contributing/development-guide/) for contributor guidelines. - -## License - -Apache License 2.0 - See [LICENSE](LICENSE) for details. +Licensed under Apache-2.0. diff --git a/SECURITY.md b/SECURITY.md index ce60fed..1e6800c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,222 +1,81 @@ # Security Policy -## Reporting Security Vulnerabilities +## Supported versions -If you discover a security vulnerability in the **ogiri** project, please report it responsibly. We appreciate your help in improving our security posture. +| Line | Security fixes | +| --------------- | ------------------------------------------------------------ | +| 4.x | Supported | +| 3.x | Critical fixes only during the published v4 migration window | +| 2.x and earlier | Unsupported | -### How to Report +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. -**Please do NOT open a public GitHub issue for security vulnerabilities.** +## Private reporting -Instead, please report security vulnerabilities privately by emailing: +Do not open a public issue for a suspected vulnerability. -- **Primary Contact:** Project Maintainers -- **Subject:** `[SECURITY] Vulnerability Report - ogiri` +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. -Include the following information in your report: +Response targets: -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) +- 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. -### Response Timeline +If the report exposes active exploitation or leaked credentials, state that clearly in the subject and revoke the credentials immediately. -We follow this responsible disclosure timeline: +## v4 security contract -- **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) +### Credential and storage model -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. +- 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. ---- +### Rotation and revocation -## Known Security Considerations +- 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. -### Token Storage +### Spring Security and transport -**Important:** This library provides token management, but security depends on proper usage: +- 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. -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 +### Subject authority -### Authentication Header +- 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. -The `Authorization` header contains token information. Ensure: +### Operations -- 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 +- 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. -### Sub-Tokens +## Verification and release controls -If using sub-tokens: +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. -- 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: +Run the local security checks with: ```bash -./gradlew dependencyCheckAnalyze +./gradlew check 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 (implement at your application level) -❌ 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](./docs/contributing.md) - ---- - -## Acknowledgments +## Disclosure -We thank all security researchers who responsibly report vulnerabilities to help us make ogiri safer for everyone. +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. diff --git a/config/dependency-check-suppressions.xml b/config/dependency-check-suppressions.xml new file mode 100644 index 0000000..3af08e8 --- /dev/null +++ b/config/dependency-check-suppressions.xml @@ -0,0 +1,15 @@ + + + + + ^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 new file mode 100644 index 0000000..0a02aeb --- /dev/null +++ b/docs/.well-known/security.txt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..739c6e1 --- /dev/null +++ b/docs/adr/0001-selector-verifier-session-core.md @@ -0,0 +1,29 @@ +# 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 index ca6193b..8938782 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,241 +1,56 @@ -# Authentication Flow +# Authentication -How ogiri authenticates requests, rotates tokens, and manages headers. +## Request flow -## Request Lifecycle +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`. -``` -Request β†’ Filter β†’ Bypass Check β†’ Header Extraction β†’ Token Validation β†’ Rotation β†’ Response -``` - -### 1. Filter Entry - -`OgiriTokenAuthenticationFilter.doFilterInternal()` intercepts every request. - -### 2. Bypass Check - -`AuthenticationBypassDecider.canSkip()` returns `true` for: - -- Already authenticated users (SecurityContext populated) -- Public routes declared in `OgiriRouteRegistry` -- CORS preflight requests (OPTIONS method) -- Health and docs paths (`/health`, `/actuator/**`, `/swagger-ui/**`) - -### 3. Header Extraction - -`AuthHeader.extractAuthHeader()` parses authentication from: - -**Individual headers (preferred):** - -``` -access-token: -client: web -uid: 123 -expiry: 2025-12-25T00:00:00Z -``` - -**Bearer token (fallback):** - -``` -Authorization: Bearer eyJhY2Nlc3MtdG9rZW4iOiJ4eXoiLCJjbGllbnQiOiJ3ZWIiLCJ1aWQiOiIxMjMiLCJleHBpcnkiOiIyMDI1LTEyLTI1In0= -``` - -The Bearer token decodes to: - -```json -{ - "access-token": "xyz", - "client": "web", - "uid": "123", - "expiry": "2025-12-25" -} -``` - -### 4. Token Validation - -`OgiriTokenService.validToken()` verifies: - -1. Token hash matches database record -2. Token is not expired -3. Grace period tokens (`lastToken`, `previousToken`) are accepted during rotation - -### 5. Token Rotation - -Based on configuration: - -| Condition | Action | -| ------------------------------------ | ---------------------------------------- | -| Within batch grace window | Update `lastUsedAt` only, no new headers | -| Outside batch window | Rotate token, emit new headers | -| `rotate-on-write-only=true` | Only rotate on POST/PUT/DELETE | -| Token exceeds `rotate-stale-seconds` | Force rotation | - -### 6. Response - -On success: - -- `SecurityContext` populated with authenticated user -- New auth headers appended (if rotated) - -On failure: - -- `SecurityContext` cleared -- `AuthenticationEntryPoint` returns error response - -## Token Rotation - -### Batch Window - -Prevents token thrashing from rapid requests: - -```yaml -ogiri: - auth: - batch-grace-seconds: 5 # Requests within 5s share same token -``` - -Within the window, only `lastUsedAt` is updated. - -### Staleness Rotation - -Force rotation after a time period: - -```yaml -ogiri: - auth: - rotate-stale-seconds: 3600 # Rotate tokens older than 1 hour -``` - -### Write-Only Rotation - -Only rotate on mutating requests: - -```yaml -ogiri: - auth: - rotate-on-write-only: true # GET requests don't rotate -``` - -## Headers - -### Request Headers - -Clients send these on authenticated requests: - -| Header | Description | -| -------------- | ----------------------------------------- | -| `access-token` | Token hash | -| `client` | Client identifier (e.g., "web", "mobile") | -| `uid` | User identifier | -| `expiry` | Token expiration (ISO-8601) | +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`. -Or use a single Bearer header containing Base64-encoded JSON. +## Issuance -### Response Headers +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. -After login or rotation: - -| Header | Description | -| -------------- | --------------------------------------- | -| `access-token` | New token hash | -| `client` | Client identifier | -| `uid` | User identifier | -| `expiry` | New expiration | -| `sub-tokens` | Base64-encoded sub-token map (optional) | - -### Sub-Token Header - -When sub-tokens are issued: - -``` -sub-tokens: eyJkZXZp******************MFoifX0= -``` - -Decodes to: - -```json -{ - "device": { - "client": "app.device", - "token": "abc123", - "expiry": "2025-12-25T00:00:00Z" - } -} -``` - -## Route Registry - -Declare unauthenticated routes: +Applications that own endpoints call the same seam: ```kotlin -@Component -class MyRouteRegistry : OgiriRouteRegistry { - override fun routes() = listOf( - OgiriRoute.get("/public/**"), - OgiriRoute.post("/api/auth/login"), - OgiriRoute.post("/api/auth/register"), - OgiriRoute.get("/health"), - OgiriRoute.get("/api/docs/**") - ) -} +val authenticated = authenticationManager.authenticate(loginRequest) +val subject = subjectResolver.resolve(authenticated) +val issued = sessions.issue(subject, clientContext) +responseWriter.writeCredential(response, issued) ``` -Routes support wildcards: +## Rotation -- `*` matches single path segment -- `**` matches multiple path segments +Rotation is a compare-and-swap command over stable session ID, expected version, and expected current digest. A successful command: -## Error Handling +- 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. -Use `SecurityServiceException` for auth errors: +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. -```kotlin -throw SecurityServiceException("error.auth.invalid_token", "Token is invalid") -``` +## Logout and session management -Recommended error codes: +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. -- `error.auth.invalid_token` -- `error.auth.expired_token` -- `error.auth.missing_headers` -- `error.auth.user_not_found` +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. -Handle in `@ControllerAdvice`: +## Account state -```kotlin -@ExceptionHandler(SecurityServiceException::class) -fun handleAuthError(ex: SecurityServiceException): ResponseEntity<*> { - return ResponseEntity - .status(HttpStatus.UNAUTHORIZED) - .body(mapOf("error" to ex.code, "message" to ex.message)) -} -``` - -## Security Best Practices - -1. **Never log raw tokens** - Use `SecurityHelpers` for parsing -2. **Register public routes** - Prevent accidental lockouts -3. **Use SecurityServiceException** - Avoid leaking internal errors -4. **Validate identifiers** - Use `IdentifierPolicy` before database queries +`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. -## Testing - -Use in-memory fixtures for testing: - -```kotlin -@Test -fun `should authenticate valid token`() { - val token = tokenService.createNewAuthToken(userId, "test-client") - - mockMvc.get("/api/protected") { - header("access-token", token.accessToken) - header("client", token.client) - header("uid", userId.toString()) - header("expiry", token.expiry.toString()) - }.andExpect { - status { isOk() } - } -} -``` +## Errors -See `OgiriTokenAuthenticationFilterTest` for comprehensive examples. +| 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/configuration.md b/docs/configuration.md index dbf578f..33c28f6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,420 +1,87 @@ # Configuration -All ogiri properties are prefixed with `ogiri`. - -## Properties Reference - -### Security Filter - -| Property | Default | Description | -| -------------------------------- | ------- | --------------------------------- | -| `ogiri.security.register-filter` | `true` | Auto-register SecurityFilterChain | - -### Token Behavior - -| Property | Default | Description | -| ----------------------------------- | ------- | ----------------------------------------------- | -| `ogiri.auth.max-clients` | `10` | Max active tokens per user | -| `ogiri.auth.batch-grace-seconds` | `5` | Grace period before rotation | -| `ogiri.auth.token-lifespan-days` | `14` | Token lifetime in days | -| `ogiri.auth.max-bearer-token-size` | `8192` | Max bearer token size in bytes (DoS protection) | -| `ogiri.auth.register-token-service` | `true` | Auto-register default OgiriTokenService | - -### Token Rotation - -| Property | Default | Description | -| --------------------------------- | ------- | --------------------------------------------- | -| `ogiri.auth.rotate-on-write-only` | `false` | Only rotate on POST/PUT/DELETE | -| `ogiri.auth.rotate-stale-seconds` | `3600` | Force rotation after N seconds (0 = disabled) | - -### Token Cleanup - -| Property | Default | Description | -| --------------------------- | ---------- | ----------------------------------------------------- | -| `ogiri.cleanup.enabled` | `true` | Enable scheduled cleanup job | -| `ogiri.cleanup.interval-ms` | `21600000` | Cleanup interval in milliseconds (default: 6 hours) | -| `ogiri.cleanup.batch-size` | `1000` | Tokens deleted per batch (large dataset optimization) | - -### Cookie Configuration - -| Property | Default | Description | -| ------------------------- | -------- | ------------------------------------ | -| `ogiri.cookies.enabled` | `true` | Enable auth cookies | -| `ogiri.cookies.secure` | `true` | Require HTTPS (WARN if false) | -| `ogiri.cookies.http-only` | `true` | Prevent JS access (WARN if false) | -| `ogiri.cookies.same-site` | `Strict` | SameSite attribute (Strict/Lax/None) | -| `ogiri.cookies.path` | `"/"` | Cookie path | - -### BCrypt Comparison Cache - -Caches the result of BCrypt comparisons to avoid repeated hashing for the same token. - -| Property | Default | Description | -| -------------------------------------- | -------------------- | ------------------------------------------------------------------------- | -| `ogiri.cache.max-size` | `10000` | Max cached token comparisons | -| `ogiri.cache.expiry-minutes` | `60` | Cache entry TTL in minutes | -| `ogiri.cache.use-spring-cache-manager` | `false` | Bridge an existing `CacheManager` bean as the token lookup cache (Tier 2) | -| `ogiri.cache.cache-name` | `ogiri-token-lookup` | Name of the Spring cache to use when `use-spring-cache-manager` is `true` | - -### Token Lookup Cache - -Caches full token entities to eliminate repeated DB reads on every authenticated request. -Disabled by default. Requires `ogiri-caffeine` or `ogiri-redis` on the classpath. - -| Property | Default | Description | -| ----------------------------- | ------- | ----------------------------------------------------------------------- | -| `ogiri.lookup.type` | (none) | `caffeine` or `redis` (case-insensitive) β€” absent means no lookup cache | -| `ogiri.lookup.max-size` | `10000` | Max cached entities (Caffeine only) | -| `ogiri.lookup.expiry-minutes` | `5` | Cache entry TTL in minutes (both Caffeine and Redis) | - -## Token Lookup Cache - -Every authenticated request calls `getByUserIdAndClient()` β€” a DB read β€” to load the token entity. -For high-traffic apps with polling endpoints, this adds up. The token lookup cache eliminates that -read for the same user/client within the configured TTL window. - -### Choosing a Backend - -| Option | How to activate | Best for | -| ------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `ogiri-caffeine` | `ogiri.lookup.type: caffeine` | Single-instance deployments, zero infrastructure | -| `ogiri-redis` | `ogiri.lookup.type: redis` | Multi-instance / containerised deployments | -| Spring CacheManager bridge | `ogiri.cache.use-spring-cache-manager: true` | Apps that already have a `CacheManager` (Ehcache, Hazelcast, etc.) and don't want an extra dependency | -| Custom `OgiriTokenLookupCache` | Provide a `@Bean` | Full control β€” required when `evictAll` must work immediately | -| (none) | (absent) | Default: every request hits the database | - -Priority when multiple options are available: custom bean β†’ `ogiri-caffeine`/`ogiri-redis` β†’ Spring CacheManager bridge. The first registered bean wins; the rest are suppressed by `@ConditionalOnMissingBean`. - -!!! warning "Multi-instance deployments" -Caffeine is **per-JVM**. If you run multiple application instances, token revocations -on one node are not visible to others until the cache entry expires. Use `ogiri-redis` -when running more than one instance. - -### ogiri-caffeine - -Add the dependency (Caffeine is included, no extra peer dep needed): - -=== "Gradle (Kotlin DSL)" - - ```kotlin - implementation("com.quantipixels.ogiri:ogiri-caffeine:{{ config.extra.ogiri_version }}") - ``` - -=== "Gradle (Groovy)" - - ```groovy - implementation 'com.quantipixels.ogiri:ogiri-caffeine:{{ config.extra.ogiri_version }}' - ``` - -=== "Maven" - - ```xml - - com.quantipixels.ogiri - ogiri-caffeine - {{ config.extra.ogiri_version }} - - ``` - -Activate in `application.yml`: - -```yaml -ogiri: - lookup: - type: caffeine - max-size: 10000 - expiry-minutes: 5 -``` - -### ogiri-redis - -Add both the Ogiri Redis module **and** the Spring Data Redis starter (peer dependency): - -=== "Gradle (Kotlin DSL)" - - ```kotlin - implementation("com.quantipixels.ogiri:ogiri-redis:{{ config.extra.ogiri_version }}") - implementation("org.springframework.boot:spring-boot-starter-data-redis") - ``` - -=== "Gradle (Groovy)" - - ```groovy - implementation 'com.quantipixels.ogiri:ogiri-redis:{{ config.extra.ogiri_version }}' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - ``` - -=== "Maven" - - ```xml - - com.quantipixels.ogiri - ogiri-redis - {{ config.extra.ogiri_version }} - - - org.springframework.boot - spring-boot-starter-data-redis - - ``` - -Activate in `application.yml` (your existing `spring.data.redis.*` config is reused automatically): - -```yaml -spring: - data: - redis: - host: localhost - port: 6379 - -ogiri: - lookup: - type: redis - expiry-minutes: 5 -``` - -### Spring CacheManager Bridge - -If your application already has a `CacheManager` bean configured β€” through Ehcache, Hazelcast, -JCache, Infinispan, or simply `spring.cache.type=redis` β€” you can reuse it as the Ogiri token -lookup cache without adding `ogiri-caffeine` or `ogiri-redis`. - -Enable the bridge in `application.yml`: - -```yaml -spring: - cache: - cache-names: ogiri-token-lookup # must declare the cache name explicitly - redis: # example: Redis-backed CacheManager - time-to-live: 300000 # 5 minutes in ms - -ogiri: - cache: - use-spring-cache-manager: true - cache-name: ogiri-token-lookup # must match a name in spring.cache.cache-names -``` - -**Backend compatibility** β€” any `CacheManager` implementation works. Ogiri only calls -`Cache.get()`, `Cache.put()`, and `Cache.evict()`, which are supported by all backends. - -!!! warning "evictAll is a no-op on this tier" -Spring's `Cache` interface has no pattern-based eviction. When all sessions for a user -are revoked (e.g. "log out everywhere"), `evictAll(userId)` logs a `WARN` and returns -without clearing the cache. Stale entries expire when the TTL elapses. For immediate -user-wide eviction, use `ogiri-redis` or provide a custom `OgiriTokenLookupCache` bean. - -!!! failure "Cache name not declared" -If `ogiri.cache.cache-name` does not appear in `spring.cache.cache-names`, the adapter -throws `IllegalStateException` on first cache access. Backends that auto-create caches -on demand (Caffeine, simple in-memory) do not require the name to be pre-declared. - -The bridge is inactive when `ogiri-caffeine` or `ogiri-redis` is on the classpath β€” the -dedicated module takes precedence via `@ConditionalOnMissingBean`. - -### Custom Cache - -Provide your own `OgiriTokenLookupCache` bean and neither autoconfiguration activates: - -```kotlin -@Component -class MyCustomTokenCache : OgiriTokenLookupCache { - override fun get(userId: Long, client: String): MyToken? = TODO() - override fun put(userId: Long, client: String, token: MyToken) = TODO() - override fun evict(userId: Long, client: String) = TODO() - override fun evictAll(userId: Long) = TODO() -} -``` - -No `ogiri.lookup.type` property is required when supplying a custom bean. - -## Configuration Examples - -### Basic Setup +Ogiri v4 is opt-in with `ogiri.session.enabled=true`. Invalid security combinations fail application startup. ```yaml ogiri: - security: - register-filter: true - auth: - max-clients: 10 - batch-grace-seconds: 5 - token-lifespan-days: 14 - max-bearer-token-size: 8192 - register-token-service: true - cleanup: - enabled: true - interval-ms: 21600000 # 6 hours - batch-size: 1000 - cache: - max-size: 10000 - expiry-minutes: 60 - # lookup.type is absent by default β€” no entity cache - cookies: + session: enabled: true - secure: true - http-only: true - same-site: Strict - path: "/" + 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:" ``` -### High Security +## Token hashing -Frequent rotation, short tokens, strict limits: +`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. -```yaml -ogiri: - auth: - max-clients: 5 - batch-grace-seconds: 1 - token-lifespan-days: 7 - max-bearer-token-size: 4096 # Stricter limit - rotate-on-write-only: false - rotate-stale-seconds: 3600 # Force rotation every hour - cleanup: - interval-ms: 3600000 # 1 hour - batch-size: 500 - cookies: - enabled: true - secure: true - http-only: true - same-site: Strict -``` +Rotation procedure: -### High Performance - -Longer tokens, less rotation: - -```yaml -ogiri: - auth: - max-clients: 50 - batch-grace-seconds: 30 - token-lifespan-days: 30 - rotate-on-write-only: true # Only rotate on writes - rotate-stale-seconds: 0 # No forced rotation -``` +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. -### Development +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. -Lenient settings for testing: +## Transport profiles -```yaml -ogiri: - auth: - max-clients: 100 - batch-grace-seconds: 60 - token-lifespan-days: 30 - cleanup: - enabled: false # Keep test tokens - cookies: - secure: false # Allow HTTP in development -``` +### Bearer -## Startup Warnings +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`. -The library logs warnings at startup for potentially insecure configurations: +### Cookie -| Configuration | Warning | -| ----------------------------------- | ---------------------------------------------------------------- | -| `ogiri.auth.rotate-stale-seconds=0` | Time-based rotation disabled; consider setting to 3600 or higher | -| `ogiri.cookies.secure=false` | Enable for HTTPS deployments | -| `ogiri.cookies.http-only=false` | Enable to prevent XSS cookie theft | -| `ogiri.lookup.type=` | Unrecognized value; no lookup cache will be activated | +Cookie mode accepts only the configured cookie and emits no readable token header or body field. Defaults: -These warnings are informational and do not prevent the application from starting. They help identify security misconfigurations in production environments. +- name `__Host-ogiri-session` +- `Secure=true` +- `HttpOnly=true` +- `SameSite=Strict` +- `Path=/` +- CSRF enabled with Spring Security's cookie token repository -## Custom Beans +`SameSite=None` requires `Secure=true`; `__Host-` requires `Secure=true` and `Path=/`. -### Custom OgiriTokenService +### DTA compatibility -If you provide your own `OgiriTokenService`, Γ’giri will not create its default token service. +`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. -If you intentionally have multiple `OgiriTokenService` beans, mark exactly one as `@Primary` or -inject by `@Qualifier` to avoid ambiguity. +## Security chain -```kotlin -@Configuration -class CustomConfig(private val properties: OgiriConfigurationProperties) { +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. - @Bean - fun tokenService( - tokenRepository: OgiriTokenRepository, - passwordEncoder: PasswordEncoder, - ogiriUserDirectory: OgiriUserDirectory, - identifierPolicy: IdentifierPolicy, - subTokenRegistry: OgiriSubTokenRegistry, - auditHook: ObjectProvider, - rateLimitHook: ObjectProvider, - lookupCache: ObjectProvider>, - ): OgiriTokenService { - val service = MyCustomTokenService( - tokenRepository, - passwordEncoder, - ogiriUserDirectory, - identifierPolicy, - subTokenRegistry, - properties, - ) - auditHook.ifAvailable { service.setAuditHook(it) } - rateLimitHook.ifAvailable { service.setRateLimitHook(it) } - lookupCache.ifAvailable { service.setLookupCache(it) } - return service - } -} -``` +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. -### Custom SecurityFilterChain +## Cleanup -Disable auto-configuration: - -```yaml -ogiri: - security: - register-filter: false -``` - -Then provide your own: - -```kotlin -@Configuration -class SecurityConfig { - - @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http - .authorizeRequests { it.anyRequest().authenticated() } - .addFilter(OgiriTokenAuthenticationFilter(tokenService, authBypassDecider)) - .build() - } -} -``` - -## Properties File Format - -```properties -ogiri.security.register-filter=true -ogiri.auth.max-clients=10 -ogiri.auth.batch-grace-seconds=5 -ogiri.auth.token-lifespan-days=14 -ogiri.auth.max-bearer-token-size=8192 -ogiri.auth.rotate-on-write-only=false -ogiri.auth.rotate-stale-seconds=3600 -ogiri.auth.register-token-service=true -ogiri.cleanup.enabled=true -ogiri.cleanup.interval-ms=21600000 -ogiri.cleanup.batch-size=1000 -ogiri.cookies.enabled=true -ogiri.cookies.secure=true -ogiri.cookies.http-only=true -ogiri.cookies.same-site=Strict -ogiri.cookies.path=/ -``` +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. -## Troubleshooting +## Distributed rate limiting -| Issue | Solution | -| ------------------------------ | ------------------------------------------------- | -| Token expires immediately | Increase `token-lifespan-days` | -| Tokens rotate too frequently | Increase `batch-grace-seconds` | -| Too many active tokens | Decrease `max-clients` | -| Tests fail with token mismatch | Set `ogiri.cleanup.enabled=false` in test profile | +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 index 7e43a37..1a2a75a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,625 +1,56 @@ # Database Integration -Ogiri is database-agnostic. Two official adapter modules are available: +## v4 support matrix -- **`ogiri-jpa`** β€” JPA/Hibernate (recommended for Spring Data users) -- **`ogiri-jdbc`** β€” Spring JDBC via `JdbcClient` (recommended when you want lightweight SQL without Hibernate) +| 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 | -## Quick Start with JPA (Recommended) +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. -### 1. Add Dependency +## Canonical schema -```kotlin -implementation("com.quantipixels.ogiri:ogiri-jpa:{{ config.extra.ogiri_version }}") -``` +`ogiri-jpa` ships `db/migration/V4__create_ogiri_sessions.sql`. Applications using Flyway discover the migration from the dependency. The schema contains: -This includes `ogiri-core` and `spring-boot-starter-data-jpa` transitively. +- 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. -### 2. Create Your Token Entity +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. -=== "Kotlin" +## Default JPA store - ```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"]) - ] - ) - class MyToken : OgiriBaseTokenEntity() - ``` +Adding `ogiri-jpa` registers `OgiriJpaSessionStore` unless the application provides another `SessionStore`. No token entity subclass or token factory is required. -=== "Java" +Atomic commands: - ```java - @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"}) - } - ) - public class MyToken extends OgiriBaseTokenEntity { - public MyToken() { - super(); - } - } - ``` +- `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. -`OgiriBaseTokenEntity` provides all fields with proper JPA annotations: +The store returns immutable `StoredSession` snapshots. Plaintext verifiers are structurally absent from the entity and migration. -- `id`, `userId`, `client`, `token`, `tokenType` -- `expiryAt`, `tokenUpdatedAt`, `createdAt`, `updatedAt` -- `previousToken`, `lastToken`, `tokenSubtype` -- `lastUsedAt`, `plainToken` (transient) +## Custom stores -### 3. Create Repository +A custom adapter implements `SessionStore`. Correctness requirements are part of the interface: -Extend both `JpaRepository` and `OgiriTokenRepository` directly: +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. -=== "Kotlin" +Run the public `ogiri-test` fixtures and the same concurrency/revocation scenarios before claiming support for a new database. - ```kotlin - @Repository - interface MyTokenRepository : - JpaRepository, OgiriTokenRepository { +## Cache and Redis - @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.userId = ?1 AND t.client IN ?2") - override fun deleteByUserIdAndClientIn(userId: Long, clients: Collection) - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.userId = ?1") - override fun deleteByUserId(userId: Long) - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.expiryAt < ?1") - override fun deleteByExpiryAtBefore(cutoff: Instant): Int - } - ``` - -=== "Java" - - ```java - @Repository - public interface MyTokenRepository - extends JpaRepository, OgiriTokenRepository { - - @Query("SELECT COUNT(t) FROM MyToken t WHERE t.userId = :userId") - @Override - long countByUserId(long userId); - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.userId = ?1 AND t.client = ?2") - @Override - void deleteByUserIdAndClient(long userId, String client); - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.userId = ?1 AND t.client IN ?2") - @Override - void deleteByUserIdAndClientIn(long userId, Collection clients); - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.userId = ?1") - @Override - void deleteByUserId(long userId); - - @Transactional @Modifying - @Query("DELETE FROM MyToken t WHERE t.expiryAt < ?1") - @Override - int deleteByExpiryAtBefore(Instant cutoff); - } - ``` - -Spring Data auto-generates these from method names: - -- `findByUserIdOrderByUpdatedAtDesc(userId)` -- `findByUserIdAndClient(userId, client)` → `Optional` -- `findByUserIdAndClientIn(userId, clients)` → `List` -- `findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(userId, tokenSubtype)` -- `findByExpiryAtBefore(cutoff)` -- `findByTokenType(tokenType)` - -### 4. Create Token Service - -=== "Kotlin" - - ```kotlin - @Service - class MyTokenService( - tokenRepository: OgiriTokenRepository, - 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, - ) = MyToken().apply { - this.userId = userId - this.client = client - this.token = hashedToken - this.tokenType = tokenType.name - this.expiryAt = expiry - this.tokenSubtype = tokenSubtype - this.plainToken = plainTokenValue - } - } - ``` - -=== "Java" - - ```java - @Service - public class MyTokenService extends OgiriTokenService { - public MyTokenService( - OgiriTokenRepository tokenRepository, - PasswordEncoder passwordEncoder, - OgiriUserDirectory userDirectory, - IdentifierPolicy identifierPolicy, - OgiriSubTokenRegistry subTokenRegistry, - OgiriConfigurationProperties properties - ) { - super(tokenRepository, passwordEncoder, userDirectory, - identifierPolicy, subTokenRegistry, properties); - } - - @Override - protected MyToken tokenFactory( - Long userId, String client, String hashedToken, - OgiriTokenType tokenType, Instant expiry, - String tokenSubtype, String plainTokenValue - ) { - MyToken token = new MyToken(); - token.setUserId(userId); - token.setClient(client); - token.setToken(hashedToken); - token.setTokenType(tokenType.name()); - token.setExpiryAt(expiry); - token.setTokenSubtype(tokenSubtype); - token.setPlainToken(plainTokenValue); - return token; - } - } - ``` - ---- - -## Quick Start with JDBC - -Use `ogiri-jdbc` for Spring JDBC (`JdbcClient`) β€” no Hibernate, no `@Entity` annotations. - -### 1. Add Dependency - -```kotlin -implementation("com.quantipixels.ogiri:ogiri-jdbc:{{ config.extra.ogiri_version }}") -``` - -This includes `ogiri-core` and `spring-boot-starter-jdbc` transitively. - -### 2. Create Your Token Row Class - -=== "Kotlin" - - ```kotlin - class MyToken : OgiriBaseTokenRow() - ``` - - No annotations needed. Extend `OgiriBaseTokenRow` and you're done. - -=== "Java" - - ```java - public class MyToken extends OgiriBaseTokenRow { - public MyToken() { - super(0L, 0L, "", "", "app", - Instant.now(), Instant.now(), Instant.now(), Instant.now()); - } - } - ``` - - Java must call the primary constructor explicitly (no `@JvmOverloads` on the Kotlin `open class`). - -`OgiriBaseTokenRow` provides all standard fields: - -- `id`, `userId`, `client`, `token`, `tokenType` -- `expiryAt`, `tokenUpdatedAt`, `createdAt`, `updatedAt` -- `previousToken`, `lastToken`, `tokenSubtype`, `lastUsedAt` -- `plainToken` (transient) - -### 3. Create Repository - -Extend `OgiriJdbcTokenRepository` and implement two methods: - -=== "Kotlin" - - ```kotlin - @Repository - class MyTokenRepository(jdbcClient: JdbcClient) : - OgiriJdbcTokenRepository(jdbcClient) { - - override fun tableName() = "user_tokens" - - override fun rowMapper() = RowMapper { rs, _ -> - MyToken().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() - createdAt = rs.getTimestamp("created_at").toInstant() - updatedAt = rs.getTimestamp("updated_at").toInstant() - tokenUpdatedAt = rs.getTimestamp("token_updated_at").toInstant() - lastToken = rs.getString("last_token_hash") - previousToken = rs.getString("previous_token_hash") - lastUsedAt = rs.getTimestamp("last_used_at")?.toInstant() - } - } - } - ``` - -=== "Java" - - ```java - @Repository - public class MyTokenRepository extends OgiriJdbcTokenRepository { - - public MyTokenRepository(JdbcClient jdbcClient) { - super(jdbcClient); - } - - @Override - public String tableName() { return "user_tokens"; } - - @Override - public RowMapper rowMapper() { - return (rs, rowNum) -> { - MyToken t = new MyToken(); - t.setId(rs.getLong("id")); - t.setUserId(rs.getLong("user_id")); - t.setClient(rs.getString("client")); - t.setToken(rs.getString("token_hash")); - t.setTokenType(rs.getString("token_type")); - t.setTokenSubtype(rs.getString("token_subtype")); - t.setExpiryAt(rs.getTimestamp("expiry_at").toInstant()); - t.setCreatedAt(rs.getTimestamp("created_at").toInstant()); - t.setUpdatedAt(rs.getTimestamp("updated_at").toInstant()); - t.setTokenUpdatedAt(rs.getTimestamp("token_updated_at").toInstant()); - t.setLastToken(rs.getString("last_token_hash")); - t.setPreviousToken(rs.getString("previous_token_hash")); - java.sql.Timestamp lastUsed = rs.getTimestamp("last_used_at"); - if (lastUsed != null) t.setLastUsedAt(lastUsed.toInstant()); - return t; - }; - } - } - ``` - -`OgiriJdbcTokenRepository` auto-implements all 15 `OgiriTokenRepository` methods. - -### 4. Create Token Service - -=== "Kotlin" - - ```kotlin - @Service - class MyTokenService( - tokenRepository: OgiriTokenRepository, - 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, - ) = MyToken().apply { - this.userId = userId - this.client = client - this.token = hashedToken - this.tokenType = tokenType.label - this.expiryAt = expiry - this.tokenSubtype = tokenSubtype - this.plainToken = plainTokenValue - } - } - ``` - -=== "Java" - - ```java - @Service - public class MyTokenService extends OgiriTokenService { - public MyTokenService( - OgiriTokenRepository tokenRepository, - PasswordEncoder passwordEncoder, - OgiriUserDirectory userDirectory, - IdentifierPolicy identifierPolicy, - OgiriSubTokenRegistry subTokenRegistry, - OgiriConfigurationProperties properties - ) { - super(tokenRepository, passwordEncoder, userDirectory, - identifierPolicy, subTokenRegistry, properties); - } - - @Override - protected MyToken tokenFactory( - Long userId, String client, String hashedToken, - OgiriTokenType tokenType, Instant expiry, - String tokenSubtype, String plainTokenValue - ) { - MyToken token = new MyToken(); - token.setUserId(userId); - token.setClient(client); - token.setToken(hashedToken); - token.setTokenType(tokenType.getLabel()); - token.setExpiryAt(expiry); - token.setTokenSubtype(tokenSubtype); - token.setPlainToken(plainTokenValue); - return token; - } - } - ``` - -!!! note "JDBC uses `tokenType.label` not `tokenType.name`" -`OgiriBaseTokenRow` stores the column value as `"app"` or `"sub"`, matching the SQL schema. -Use `tokenType.label` (Kotlin) or `tokenType.getLabel()` (Java) β€” not `.name` / `.name()`. - -### 5. Configure Application - -Exclude JPA auto-configuration and point Spring at the bundled schema: - -```yaml -spring: - autoconfigure: - exclude: - - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration - datasource: - url: jdbc:postgresql://localhost:5432/mydb - username: ${DB_USER} - password: ${DB_PASS} - sql: - init: - schema-locations: classpath:ogiri/db/ogiri-user-tokens.sql - mode: always -``` - -### Switching Between JPA and JDBC (Spring Profiles) - -The samples demonstrate profile-based switching. Annotate your JPA service with `@Profile("!jdbc")` and your JDBC service with `@Profile("jdbc")`, then activate via `--spring.profiles.active=jdbc`. - ---- - -## Other Databases - -For non-JPA databases, implement `OgiriTokenRepository` directly using `ogiri-core`. - -```kotlin -implementation("com.quantipixels.ogiri:ogiri-core:{{ config.extra.ogiri_version }}") -``` - -### MongoDB - -```kotlin -@Repository -class MongoTokenRepository( - private val mongoTemplate: MongoTemplate -) : OgiriTokenRepository { - - override fun save(token: S): S = mongoTemplate.save(token) - - override fun findById(id: Long): Optional = - Optional.ofNullable(mongoTemplate.findById(id, MongoToken::class.java)) - - override fun findByUserIdAndClient(userId: Long, client: String): Optional { - val query = Query(Criteria.where("userId").`is`(userId).and("client").`is`(client)) - return Optional.ofNullable(mongoTemplate.findOne(query, MongoToken::class.java)) - } - - override fun findByUserIdOrderByUpdatedAtDesc(userId: Long): List { - val query = Query(Criteria.where("userId").`is`(userId)) - .with(Sort.by(Sort.Direction.DESC, "updatedAt")) - return mongoTemplate.find(query, MongoToken::class.java) - } - - override fun findByExpiryAtBefore(cutoff: Instant): List { - val query = Query(Criteria.where("expiryAt").lt(cutoff)) - return mongoTemplate.find(query, MongoToken::class.java) - } - - override fun delete(token: MongoToken) { - mongoTemplate.remove(Query(Criteria.where("_id").`is`(token.id)), MongoToken::class.java) - } - - override fun deleteById(id: Long) { - mongoTemplate.remove(Query(Criteria.where("_id").`is`(id)), MongoToken::class.java) - } - - // ... implement remaining methods -} -``` - -### Redis - -```kotlin -@Repository -class RedisTokenRepository( - private val redisTemplate: RedisTemplate -) : OgiriTokenRepository { - - override fun findByUserIdAndClient(userId: Long, client: String): Optional = - Optional.ofNullable(redisTemplate.opsForValue().get("token:$userId:$client")) - - override fun save(token: S): S { - val key = "token:${token.userId}:${token.client}" - val ttl = Duration.between(Instant.now(), token.expiryAt) - if (ttl.isNegative || ttl.isZero) return token - redisTemplate.opsForValue().set(key, token, ttl) - return token - } - - override fun findByExpiryAtBefore(cutoff: Instant): List = - emptyList() // Redis handles TTL automatically - - override fun delete(token: Token) { - redisTemplate.delete("token:${token.userId}:${token.client}") - } - - // ... implement remaining methods -} -``` - ---- - -## Token Model Requirements - -| Field | Type | Required | Description | -| ---------------- | ------- | -------- | ------------------------------- | -| `id` | Long | Yes | Primary key | -| `userId` | Long | Yes | User identifier | -| `client` | String | Yes | Client/device identifier | -| `token` | String | Yes | BCrypt hash | -| `tokenType` | String | Yes | "app" or "sub" | -| `expiryAt` | Instant | Yes | Expiration timestamp | -| `createdAt` | Instant | Yes | Creation timestamp | -| `updatedAt` | Instant | Yes | Last update | -| `tokenUpdatedAt` | Instant | Yes | Last rotation | -| `lastToken` | String | No | Previous token (rotation grace) | -| `previousToken` | String | No | Token before last | -| `tokenSubtype` | String | No | Sub-token name | -| `lastUsedAt` | Instant | No | Last access | - -**Constraints:** - -- Unique constraint on `(userId, client)` -- Index on `userId` and `expiryAt` - ---- - -## Bundled SQL Schemas - -Schemas are bundled in `ogiri-core/src/main/resources/ogiri/db/`: - -| Database | File | -| ---------- | ----------------------------- | -| PostgreSQL | `ogiri-user-tokens.sql` | -| MySQL | `ogiri-user-tokens-mysql.sql` | -| H2 | `ogiri-user-tokens-h2.sql` | - -### Using with Flyway - -```yaml -spring: - flyway: - locations: classpath:db/migration,classpath:/ogiri/db -``` - -### PostgreSQL Schema - -```sql -CREATE TABLE 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 uk_user_tokens_user_client ON user_tokens (user_id, client); -CREATE INDEX idx_user_tokens_user_id ON user_tokens (user_id); -CREATE INDEX idx_user_tokens_expiry ON user_tokens (expiry_at); -CREATE INDEX idx_user_tokens_user_subtype ON user_tokens (user_id, token_subtype); -``` - -## Recommended Indexes - -| Index | Columns | Purpose | -| ------------------------------ | ------------------------ | ----------------------- | -| `idx_user_tokens_user_id` | `user_id` | User token lookups | -| `idx_user_tokens_expiry` | `expiry_at` | Cleanup job performance | -| `uk_user_tokens_user_client` | `user_id, client` | Unique token lookup | -| `idx_user_tokens_user_subtype` | `user_id, token_subtype` | Sub-token queries | - -## Repository Methods - -The `OgiriTokenRepository` interface includes the following methods: - -### Core CRUD - -| Method | Description | -| --------------------------- | ---------------------- | -| `save(token): T` | Create or update token | -| `findById(id): Optional` | Find by primary key | -| `delete(token)` | Delete token | -| `deleteById(id)` | Delete by primary key | - -### Query Methods - -| Method | Description | -| --------------------------------------------------------------------------- | --------------------------------------- | -| `findByUserIdOrderByUpdatedAtDesc(userId): List` | All tokens for a user, newest first | -| `findByUserIdAndClient(userId, client): Optional` | Token for a specific user+client | -| `findByUserIdAndClientIn(userId, clients): List` | Batch fetch tokens for multiple clients | -| `findByUserIdAndTokenSubtypeOrderByUpdatedAtDesc(userId, subtype): List` | Sub-tokens by type | -| `findByExpiryAtBefore(cutoff): List` | Expired tokens for cleanup | -| `findByTokenType(tokenType): List` | Tokens by type | -| `countByUserId(userId): Long` | Count tokens for a user | - -### Delete Methods - -| Method | Description | -| -------------------------------------------- | -------------------------------- | -| `deleteByUserIdAndClient(userId, client)` | Single-device logout | -| `deleteByUserIdAndClientIn(userId, clients)` | Bulk session revocation | -| `deleteByUserId(userId)` | Global logout / account deletion | -| `deleteByExpiryAtBefore(cutoff): Int` | Cleanup expired tokens | - ---- - -## Best Practices - -1. **Use `ogiri-jpa`** - Reduces boilerplate by ~70% for JPA users -2. **Always hash tokens** - Never store plaintext tokens -3. **Index `expiryAt`** - Enables fast cleanup queries -4. **Use connection pooling** - HikariCP recommended for production -5. **Monitor table size** - Archive old tokens if needed -6. **Test with your database** - Different databases have quirks -7. **Override `countByUserId`** - Use `@Query` for performance instead of loading all tokens +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/gradle/version.gradle.kts b/gradle/version.gradle.kts index d29ee90..4f72509 100644 --- a/gradle/version.gradle.kts +++ b/gradle/version.gradle.kts @@ -1,36 +1,18 @@ /** - * Apply centralized project version to all subprojects. + * Applies one lazy version provider to every project. * - * Version is defined in .ogiri-version file as the single source of truth. - * This script applies it to all modules (ogiri-core, sample-java, sample-kotlin). - * - * Version sources (in order of precedence): - * 1. Environment variable RELEASE_VERSION - Manual releases & CI/CD - * 2. Gradle property -PRELEASE_VERSION - Local builds - * 3. .ogiri-version file - Default version - * 4. Fallback: UNVERSIONED (if file doesn't exist) - * - * Usage: - * gradle build # Uses version from .ogiri-version - * RELEASE_VERSION=1.0.3 gradle build # Uses 1.0.3 - * gradle -PRELEASE_VERSION=1.0.3 build # Uses 1.0.3 + * 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") -// Read version from .ogiri-version file -val versionFile = rootProject.file(".ogiri-version") -val versionFromFile = if (versionFile.exists()) { - versionFile.readText().trim().takeIf { it.isNotBlank() } -} else { - null -} - -val envVersion = System.getenv("RELEASE_VERSION") -val propVersion = System.getProperty("RELEASE_VERSION") - -// Version resolution with proper precedence -val resolvedVersion = envVersion ?: propVersion ?: versionFromFile ?: "UNVERSIONED" - -// Apply to all subprojects -rootProject.allprojects { - version = resolvedVersion -} +rootProject.allprojects { version = resolvedVersion.get() } diff --git a/ogiri-bom/build.gradle.kts b/ogiri-bom/build.gradle.kts new file mode 100644 index 0000000..5e921a8 --- /dev/null +++ b/ogiri-bom/build.gradle.kts @@ -0,0 +1,46 @@ +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"]) } } + repositories { + maven { + name = "OSSRH" + val releasesUrl = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + val snapshotsUrl = uri("https://s01.oss.sonatype.org/content/repositories/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-core/build.gradle.kts b/ogiri-core/build.gradle.kts index 48f3584..795fa40 100644 --- a/ogiri-core/build.gradle.kts +++ b/ogiri-core/build.gradle.kts @@ -31,6 +31,12 @@ kotlin { 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()}") @@ -38,6 +44,7 @@ dependencyManagement { } 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") @@ -52,15 +59,19 @@ dependencies { // 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") { @@ -68,6 +79,16 @@ dependencies { } } +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 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 index c5603f9..d4b879c 100644 --- 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 @@ -35,6 +35,7 @@ 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 @@ -277,6 +278,7 @@ class OgiriSecurityAutoConfiguration { fun ogiriTokenAuthenticationFilter( ogiriUserDirectory: OgiriUserDirectory, tokenServiceResolver: OgiriTokenServiceResolver, + @Qualifier("ogiriAuthenticationEntryPoint") authenticationEntryPoint: AuthenticationEntryPoint, authenticationBypassDecider: AuthenticationBypassDecider, identifierPolicy: IdentifierPolicy, @@ -340,6 +342,7 @@ class OgiriSecurityAutoConfiguration { fun ogiriSecurityFilterChain( http: HttpSecurity, ogiriTokenAuthenticationFilter: OgiriTokenAuthenticationFilter, + @Qualifier("ogiriAuthenticationEntryPoint") authenticationEntryPoint: AuthenticationEntryPoint, properties: OgiriConfigurationProperties, ): SecurityFilterChain = 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 new file mode 100644 index 0000000..b2845f9 --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriHttpConfigurer.kt @@ -0,0 +1,117 @@ +/* + * 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.http.HttpServletRequest +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 + +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()) } + } 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) {} + } +} 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 new file mode 100644 index 0000000..8e2bb7c --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandler.kt @@ -0,0 +1,80 @@ +/* + * 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 new file mode 100644 index 0000000..3f6f9fc --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriRateLimiter.kt @@ -0,0 +1,38 @@ +/* + * 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 new file mode 100644 index 0000000..4cfc332 --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAuthentication.kt @@ -0,0 +1,157 @@ +/* + * 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 new file mode 100644 index 0000000..3533261 --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionAutoConfiguration.kt @@ -0,0 +1,329 @@ +/* + * 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 new file mode 100644 index 0000000..9ecd2c7 --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanup.kt @@ -0,0 +1,120 @@ +/* + * 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 new file mode 100644 index 0000000..2faf73a --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionEndpoints.kt @@ -0,0 +1,250 @@ +/* + * 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 new file mode 100644 index 0000000..07a1ccf --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionMetrics.kt @@ -0,0 +1,45 @@ +/* + * 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 new file mode 100644 index 0000000..42bee36 --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionObservabilityAutoConfiguration.kt @@ -0,0 +1,40 @@ +/* + * 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 new file mode 100644 index 0000000..6d32dbf --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionProperties.kt @@ -0,0 +1,153 @@ +/* + * 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 new file mode 100644 index 0000000..a044331 --- /dev/null +++ b/ogiri-core/src/main/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponses.kt @@ -0,0 +1,127 @@ +/* + * 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/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 index 55afb5c..fa04ba3 100644 --- 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 @@ -1 +1,2 @@ -com.quantipixels.ogiri.security.config.OgiriSecurityAutoConfiguration +com.quantipixels.ogiri.security.session.OgiriSessionAutoConfiguration +com.quantipixels.ogiri.security.session.OgiriSessionObservabilityAutoConfiguration 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 new file mode 100644 index 0000000..d7a64fa --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriBearerAuthenticationConverterTest.kt @@ -0,0 +1,76 @@ +/* + * 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 new file mode 100644 index 0000000..8bb1c7f --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriCookieSecurityTest.kt @@ -0,0 +1,92 @@ +/* + * 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.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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post +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()), + ) + + mockMvc.post("/protected") { cookie(cookie) }.andExpect { status { isForbidden() } } + mockMvc + .post("/protected") { + cookie(cookie) + with(csrf()) + } + .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 { + @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 new file mode 100644 index 0000000..4b2b78e --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriEndpointTransactionTest.kt @@ -0,0 +1,134 @@ +/* + * 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 new file mode 100644 index 0000000..3fe60da --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriProblemHandlerTest.kt @@ -0,0 +1,45 @@ +/* + * 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 new file mode 100644 index 0000000..14558d0 --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSecureChainTest.kt @@ -0,0 +1,184 @@ +/* + * 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 new file mode 100644 index 0000000..314d67c --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionCleanupTest.kt @@ -0,0 +1,167 @@ +/* + * 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 new file mode 100644 index 0000000..143227e --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionPropertiesTest.kt @@ -0,0 +1,37 @@ +/* + * 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 new file mode 100644 index 0000000..106c3fd --- /dev/null +++ b/ogiri-core/src/test/kotlin/com/quantipixels/ogiri/security/session/OgiriSessionResponseWriterTest.kt @@ -0,0 +1,69 @@ +/* + * 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/example/consumer/OgiriEndpointAutoConfigurationTest.kt b/ogiri-core/src/test/kotlin/example/consumer/OgiriEndpointAutoConfigurationTest.kt new file mode 100644 index 0000000..770c026 --- /dev/null +++ b/ogiri-core/src/test/kotlin/example/consumer/OgiriEndpointAutoConfigurationTest.kt @@ -0,0 +1,103 @@ +/* + * 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-jdbc/build.gradle.kts b/ogiri-jdbc/build.gradle.kts index fb3251e..3bbefd1 100644 --- a/ogiri-jdbc/build.gradle.kts +++ b/ogiri-jdbc/build.gradle.kts @@ -32,6 +32,7 @@ dependencyManagement { 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") { diff --git a/ogiri-jpa/build.gradle.kts b/ogiri-jpa/build.gradle.kts index e94ed51..8abddd6 100644 --- a/ogiri-jpa/build.gradle.kts +++ b/ogiri-jpa/build.gradle.kts @@ -33,6 +33,7 @@ dependencyManagement { 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") { 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 index d0e9efa..c7c4c73 100644 --- a/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaAutoConfiguration.kt +++ b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaAutoConfiguration.kt @@ -12,35 +12,37 @@ */ package com.quantipixels.ogiri.jpa -import com.quantipixels.ogiri.security.config.OgiriSecurityAutoConfiguration -import org.springframework.boot.autoconfigure.AutoConfigureAfter +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.context.annotation.Configuration +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 -/** - * Auto-configuration for Ogiri JPA support. - * - * This configuration is automatically loaded when: - * - Spring Data JPA is on the classpath (JpaRepository class present) - * - After OgiriSecurityAutoConfiguration has been processed - * - * Users should create: - * - Token entity extending [OgiriBaseTokenEntity] - * - Repository interface extending both JpaRepository and OgiriTokenRepository - * - * Example: - * ```kotlin - * @Repository - * interface MyTokenRepository : - * JpaRepository, - * OgiriTokenRepository - * ``` - * - * Spring Data automatically generates all query implementations when method names follow Spring - * Data naming conventions. - */ -@Configuration +@AutoConfiguration( + after = [HibernateJpaAutoConfiguration::class], + before = [OgiriSessionAutoConfiguration::class], +) @ConditionalOnClass(JpaRepository::class) -@AutoConfigureAfter(OgiriSecurityAutoConfiguration::class) -class OgiriJpaAutoConfiguration +@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 new file mode 100644 index 0000000..705f275 --- /dev/null +++ b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaEntityScanRegistrar.kt @@ -0,0 +1,35 @@ +/* + * 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 new file mode 100644 index 0000000..e4b9764 --- /dev/null +++ b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaJobLease.kt @@ -0,0 +1,89 @@ +/* + * 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) ?: return + entityManager.lock(lease, LockModeType.PESSIMISTIC_WRITE) + 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 new file mode 100644 index 0000000..498b3f8 --- /dev/null +++ b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaSessionStore.kt @@ -0,0 +1,334 @@ +/* + * 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 + } + } + entityManager.clear() + return RotateSessionResult.Rotated( + entityManager.find(OgiriSessionEntity::class.java, command.sessionId.value).toDomain()) + } + + 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 + entityManager.clear() + return RevokeSessionResult.Revoked( + entityManager.find(OgiriSessionEntity::class.java, command.sessionId.value).toDomain()) + } + + 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 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 new file mode 100644 index 0000000..3f1c05d --- /dev/null +++ b/ogiri-jpa/src/main/kotlin/com/quantipixels/ogiri/jpa/OgiriSessionEntity.kt @@ -0,0 +1,112 @@ +/* + * 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/db/migration/V4__create_ogiri_sessions.sql b/ogiri-jpa/src/main/resources/db/migration/V4__create_ogiri_sessions.sql new file mode 100644 index 0000000..ca46711 --- /dev/null +++ b/ogiri-jpa/src/main/resources/db/migration/V4__create_ogiri_sessions.sql @@ -0,0 +1,73 @@ +-- 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 new file mode 100644 index 0000000..f4d7295 --- /dev/null +++ b/ogiri-jpa/src/test/kotlin/com/quantipixels/ogiri/jpa/OgiriJpaStarterIntegrationTest.kt @@ -0,0 +1,210 @@ +/* + * 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 java.time.Duration +import java.time.Instant +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +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.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 + +@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 + + @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 `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" }) + } + + @SpringBootApplication + class TestApplication { + @Bean + fun users(): UserDetailsService = + InMemoryUserDetailsManager( + User.withUsername("user-42").password("{noop}password").roles("USER").build()) + } +} 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 index ac64999..bdbcb3f 100644 --- 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 @@ -14,15 +14,19 @@ 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]. @@ -49,3 +53,19 @@ class OgiriRedisAutoConfiguration { 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 new file mode 100644 index 0000000..82d942f --- /dev/null +++ b/ogiri-redis/src/main/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiter.kt @@ -0,0 +1,74 @@ +/* + * 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/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 index b9a5e69..07e1070 100644 --- 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 @@ -1 +1,2 @@ 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/OgiriRedisRateLimiterTest.kt b/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiterTest.kt new file mode 100644 index 0000000..97ad78f --- /dev/null +++ b/ogiri-redis/src/test/kotlin/com/quantipixels/ogiri/security/redis/OgiriRedisRateLimiterTest.kt @@ -0,0 +1,91 @@ +/* + * 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-session-core/build.gradle.kts b/ogiri-session-core/build.gradle.kts new file mode 100644 index 0000000..18886a8 --- /dev/null +++ b/ogiri-session-core/build.gradle.kts @@ -0,0 +1,65 @@ +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"]) } } + repositories { + maven { + name = "OSSRH" + val releasesUrl = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + val snapshotsUrl = uri("https://s01.oss.sonatype.org/content/repositories/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 new file mode 100644 index 0000000..f2aa6d7 --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/IdentifierGenerator.kt @@ -0,0 +1,29 @@ +/* + * 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 new file mode 100644 index 0000000..f97482f --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/OgiriSessions.kt @@ -0,0 +1,66 @@ +/* + * 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 new file mode 100644 index 0000000..53a5ede --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionManager.kt @@ -0,0 +1,363 @@ +/* + * 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 new file mode 100644 index 0000000..751ae15 --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionModel.kt @@ -0,0 +1,239 @@ +/* + * 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 new file mode 100644 index 0000000..771add7 --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/SessionStore.kt @@ -0,0 +1,116 @@ +/* + * 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 new file mode 100644 index 0000000..f2b3b94 --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenCodec.kt @@ -0,0 +1,98 @@ +/* + * 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 new file mode 100644 index 0000000..8b9a88d --- /dev/null +++ b/ogiri-session-core/src/main/kotlin/com/quantipixels/ogiri/session/TokenHasher.kt @@ -0,0 +1,77 @@ +/* + * 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 new file mode 100644 index 0000000..46c0df1 --- /dev/null +++ b/ogiri-session-core/src/test/java/com/quantipixels/ogiri/session/JavaConsumerTest.java @@ -0,0 +1,60 @@ +/* + * 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 new file mode 100644 index 0000000..fcbf766 --- /dev/null +++ b/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionManagerTest.kt @@ -0,0 +1,327 @@ +/* + * 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 new file mode 100644 index 0000000..3a0d76b --- /dev/null +++ b/ogiri-session-core/src/test/kotlin/com/quantipixels/ogiri/session/SessionModelTest.kt @@ -0,0 +1,61 @@ +/* + * 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-test/build.gradle.kts b/ogiri-test/build.gradle.kts new file mode 100644 index 0000000..5dbc2ec --- /dev/null +++ b/ogiri-test/build.gradle.kts @@ -0,0 +1,57 @@ +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"]) } } + repositories { + maven { + name = "OSSRH" + val releasesUrl = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + val snapshotsUrl = uri("https://s01.oss.sonatype.org/content/repositories/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 new file mode 100644 index 0000000..5f062d1 --- /dev/null +++ b/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/InMemorySessionStore.kt @@ -0,0 +1,151 @@ +/* + * 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 new file mode 100644 index 0000000..d1c4838 --- /dev/null +++ b/ogiri-test/src/main/kotlin/com/quantipixels/ogiri/test/OgiriTestSupport.kt @@ -0,0 +1,62 @@ +/* + * 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/sample/sample-java/build.gradle.kts b/sample/sample-java/build.gradle.kts index 8d69043..63140e6 100644 --- a/sample/sample-java/build.gradle.kts +++ b/sample/sample-java/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { runtimeOnly("com.h2database:h2:2.4.240") runtimeOnly("org.postgresql:postgresql:42.7.8") + testImplementation(project(":ogiri-test")) testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(module = "mockito-core") } 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 new file mode 100644 index 0000000..1bd7695 --- /dev/null +++ b/sample/sample-java/src/test/java/com/quantipixels/ogiri/samples/java/V4QuickstartTest.java @@ -0,0 +1,46 @@ +/* + * 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-kotlin/build.gradle.kts b/sample/sample-kotlin/build.gradle.kts index 78511d8..b48f359 100644 --- a/sample/sample-kotlin/build.gradle.kts +++ b/sample/sample-kotlin/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { runtimeOnly("com.h2database:h2:2.4.240") runtimeOnly("org.postgresql:postgresql:42.7.8") + testImplementation(project(":ogiri-test")) testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(module = "mockito-core") } 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 new file mode 100644 index 0000000..44874f6 --- /dev/null +++ b/sample/sample-kotlin/src/test/kotlin/com/quantipixels/ogiri/samples/kotlin/V4QuickstartTest.kt @@ -0,0 +1,47 @@ +/* + * 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/settings.gradle.kts b/settings.gradle.kts index 5bc4547..3bf25a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,29 +1,13 @@ rootProject.name = "ogiri" -/* - * Centralized version management. - * - * Version is read from .ogiri-version file (primary source of truth). - * Version determination order (highest to lowest precedence): - * 1. Environment variable RELEASE_VERSION (CI/CD overrides) - * 2. Gradle property -PRELEASE_VERSION (local overrides) - * 3. .ogiri-version file (main version) - * 4. Default fallback: 1.0.2 - * - * To update version: - * - Edit .ogiri-version file - * - Or override: RELEASE_VERSION=1.0.X gradle build - * - Or use: scripts/release.sh (creates tag and pushes to GitHub) - */ -val versionFile = File(settingsDir, ".ogiri-version") -val projectVersion = - System.getenv("RELEASE_VERSION")?.takeIf { it.isNotBlank() } - ?: (System.getProperty("RELEASE_VERSION")?.takeIf { it.isNotBlank() }) - ?: (if (versionFile.exists()) versionFile.readText().trim() else null) - ?: "0.0.0-SNAPSHOT" - include(":ogiri-core") +include(":ogiri-session-core") + +include(":ogiri-bom") + +include(":ogiri-test") + include(":ogiri-jpa") include(":ogiri-jdbc") @@ -78,11 +62,11 @@ dependencyResolutionManagement { // Plugin versions version("kotlin", "2.1.20") version("spotless", "8.0.0") - version("springBoot", "3.5.7") + version("springBoot", "3.5.16") version("dependencyManagement", "1.1.7") version("versionsPlugin", "0.52.0") - version("caffeine", "3.2.3") - version("owasp", "12.1.9") + version("caffeine", "3.2.4") + version("owasp", "12.2.2") version("jacoco", "0.8.11") } }