diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a862f02..de705e0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,21 +1,53 @@ name: Publish Aether on: + pull_request: + branches: + - main push: branches: - main workflow_dispatch: + inputs: + hardware_passkey_smoke_confirmed: + description: Firefox and Safari hardware smoke, including a second passkey and session revocation, completed successfully + required: true + type: boolean + default: false + hardware_passkey_smoke_evidence: + description: Non-secret Linear/evidence reference for the Firefox and Safari smoke results + required: true + type: string + adversarial_review_confirmed: + description: Independent adversarial identity review completed successfully + required: true + type: boolean + default: false + adversarial_review_evidence: + description: Non-secret Linear/evidence reference for adversarial review findings and disposition + required: true + type: string permissions: - contents: write - packages: write + contents: read jobs: - release: + verify: + permissions: + contents: read runs-on: ubuntu-latest env: GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx2g -Dorg.gradle.vfs.watch=false -Dorg.gradle.daemon=false" steps: + - name: Require a main-branch manual release + if: github.event_name == 'workflow_dispatch' + shell: bash + run: | + [[ "$GITHUB_REF" == "refs/heads/main" ]] || { + echo "::error::Manual publishing is allowed only from refs/heads/main (received $GITHUB_REF)." + exit 1 + } + - name: Checkout uses: actions/checkout@v4 with: @@ -34,6 +66,13 @@ jobs: java-version: '21' distribution: 'temurin' + - name: Set up Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: e2e-tests/package-lock.json + - name: Cache Gradle and build outputs uses: actions/cache@v4 with: @@ -44,7 +83,7 @@ jobs: ~/.gradle/yarn build */build - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts', '**/gradle.properties', 'gradle/libs.versions.toml') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts', '**/gradle.properties', 'gradle/libs.versions.toml', '**/gradle.lockfile', 'settings-gradle.lockfile', 'gradle/verification-metadata.xml') }} restore-keys: | ${{ runner.os }}-gradle- @@ -62,6 +101,22 @@ jobs: fi fi + - name: Install locked browser-test dependencies + run: npm ci --prefix e2e-tests + + - name: Verify browser-test contracts and types + run: | + npm test --prefix e2e-tests + npm run typecheck --prefix e2e-tests + + - name: Install Playwright browsers + run: npm exec --prefix e2e-tests -- playwright install --with-deps chromium firefox webkit + + - name: Expose Playwright Chromium to Kotlin browser tests + shell: bash + run: | + node -e "const fs=require('fs'); const {chromium}=require('./e2e-tests/node_modules/@playwright/test'); fs.appendFileSync(process.env.GITHUB_ENV, 'CHROME_BIN='+chromium.executablePath()+'\n')" + - name: Run tests shell: bash run: | @@ -69,7 +124,7 @@ jobs: RETRY_DELAY=30 for i in $(seq 1 $MAX_RETRIES); do echo "Attempt $i of $MAX_RETRIES" - if ./gradlew check -x wasmJsBrowserTest -x :example-app:test --no-daemon --stacktrace; then + if ./gradlew verifyExpectedSourceTasks check -x wasmJsBrowserTest --dependency-verification=strict --no-daemon --stacktrace; then exit 0 fi if [ $i -lt $MAX_RETRIES ]; then @@ -81,6 +136,125 @@ jobs: echo "Build failed after $MAX_RETRIES attempts" exit 1 + - name: Install the official Firestore emulator + uses: google-github-actions/setup-gcloud@v3.0.1 + with: + version: '570.0.0' + install_components: cloud-firestore-emulator + + - name: Run the real Firestore REST transaction gate + env: + AETHER_FIRESTORE_EMULATOR_PROJECT_ID: aether-identity-emulator-${{ github.run_id }}-${{ github.run_attempt }} + run: ./aether-auth-firestore/run-emulator-gate.sh + + - name: Run Kotlin browser tests + run: | + ./gradlew :aether-auth-summon:wasmJsBrowserTest :example-app:wasmJsBrowserTest --dependency-verification=strict --no-daemon --stacktrace + + - name: Run Chromium, Firefox, and WebKit identity UI tests + run: npm run test:browser --prefix e2e-tests + + - name: Run disposable passkey and recovery journeys + env: + AETHER_E2E_LIVE_IDENTITY: '1' + AETHER_E2E_EPHEMERAL: '1' + AETHER_E2E_RECOVERY_FLOW: '1' + PLAYWRIGHT_NO_COPY_PROMPT: '1' + AETHER_E2E_BASE_URL: http://localhost:8080 + AETHER_IDENTITY_BOOTSTRAP_SECRET: aether-ci-disposable-bootstrap-secret + run: npm run test:release --prefix e2e-tests + + - name: Delete sensitive live-journey output + if: always() + run: rm -rf e2e-tests/.live-sensitive-results + + - name: Upload browser failure evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: identity-browser-failures + path: | + e2e-tests/test-results + e2e-tests/playwright-report + if-no-files-found: ignore + retention-days: 7 + + - name: Verify OpenSSL WASI host library primitives + shell: bash + run: | + cmake -S aether-identity-wasi-host -B build/identity-wasi-host -DCMAKE_BUILD_TYPE=Release + cmake --build build/identity-wasi-host --config Release --parallel 2 + ctest --test-dir build/identity-wasi-host --output-on-failure + + - name: Verify combined wasmWasi component host + if: github.event_name == 'workflow_dispatch' + shell: bash + run: | + test -x aether-identity-wasi-host/run-component-gate.sh || { + echo "::error::The Kotlin guest/OpenSSL crypto/wasi:http component gate is not implemented; publishing is blocked." + exit 1 + } + ./aether-identity-wasi-host/run-component-gate.sh + + publish: + if: github.event_name == 'workflow_dispatch' + needs: verify + permissions: + contents: write + runs-on: ubuntu-latest + env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx2g -Dorg.gradle.vfs.watch=false -Dorg.gradle.daemon=false" + steps: + - name: Require a main-branch manual release + shell: bash + run: | + [[ "$GITHUB_REF" == "refs/heads/main" ]] || { + echo "::error::Manual publishing is allowed only from refs/heads/main (received $GITHUB_REF)." + exit 1 + } + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Grant execute permissions + run: | + chmod +x gradlew + if [ -f sign-artifact.sh ]; then + chmod +x sign-artifact.sh + fi + + - name: Set up Temurin JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Read release version + id: version + shell: bash + run: | + VERSION=$(grep "^VERSION=" version.properties | cut -d'=' -f2) + [[ -n "$VERSION" ]] || { echo "::error::version.properties has no VERSION"; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify manual hardware-passkey release gate + env: + HARDWARE_SMOKE_CONFIRMED: ${{ inputs.hardware_passkey_smoke_confirmed }} + HARDWARE_SMOKE_EVIDENCE: ${{ inputs.hardware_passkey_smoke_evidence }} + run: | + [[ "$HARDWARE_SMOKE_CONFIRMED" == "true" ]] || { echo "::error::Firefox and Safari hardware-passkey smoke, including second-passkey and session-revocation checks, is not confirmed"; exit 1; } + [[ -n "$HARDWARE_SMOKE_EVIDENCE" ]] || { echo "::error::A non-secret hardware-smoke evidence reference is required"; exit 1; } + + - name: Verify adversarial-review release gate + env: + ADVERSARIAL_REVIEW_CONFIRMED: ${{ inputs.adversarial_review_confirmed }} + ADVERSARIAL_REVIEW_EVIDENCE: ${{ inputs.adversarial_review_evidence }} + run: | + [[ "$ADVERSARIAL_REVIEW_CONFIRMED" == "true" ]] || { echo "::error::Independent adversarial identity review is not confirmed"; exit 1; } + [[ -n "$ADVERSARIAL_REVIEW_EVIDENCE" ]] || { echo "::error::A non-secret adversarial-review evidence reference is required"; exit 1; } + - name: Ensure publishing secrets are present shell: bash run: | @@ -113,7 +287,6 @@ jobs: chmod 600 private-key.asc - # Validate the signing key is importable before proceeding (fail fast) TMP_GNUPGHOME=$(mktemp -d) export GNUPGHOME="$TMP_GNUPGHOME" if ! gpg --batch --yes --import private-key.asc >/tmp/gpg-import.log 2>&1; then @@ -127,11 +300,13 @@ jobs: - name: Publish Aether to Maven Central shell: bash run: | + MAVEN_REPO="$RUNNER_TEMP/aether-release-m2-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$MAVEN_REPO" MAX_RETRIES=3 RETRY_DELAY=30 for i in $(seq 1 $MAX_RETRIES); do echo "Attempt $i of $MAX_RETRIES" - if ./gradlew publishToCentralPortalManually --no-daemon --stacktrace --info; then + if ./gradlew -Dmaven.repo.local="$MAVEN_REPO" publishToCentralPortalManually --no-daemon --stacktrace --info; then exit 0 fi if [ $i -lt $MAX_RETRIES ]; then @@ -148,8 +323,6 @@ jobs: shell: bash run: | python3 <<'PY' - import itertools - try: with open("CHANGELOG.md", encoding="utf-8") as fh: lines = fh.readlines() @@ -173,14 +346,14 @@ jobs: with open("release-notes.md", "w", encoding="utf-8") as out: out.write(notes + "\n") PY - + if [ -f release-notes.md ]; then NOTES=$(cat release-notes.md) echo "notes<> "$GITHUB_OUTPUT" echo "$NOTES" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" else - echo "notes=" >> "$GITHUB_OUTPUT" + echo "notes=" >> "$GITHUB_OUTPUT" fi - name: Create and push release tag @@ -190,10 +363,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" if git rev-parse "v${VERSION}" >/dev/null 2>&1; then - echo "Tag v${VERSION} already exists, skipping." + echo "Tag v${VERSION} already exists, skipping." else - git tag -a "v${VERSION}" -m "Aether ${VERSION}" - git push origin "v${VERSION}" + git tag -a "v${VERSION}" -m "Aether ${VERSION}" + git push origin "v${VERSION}" fi - name: Create GitHub release diff --git a/CHANGELOG.md b/CHANGELOG.md index 5492019..0908d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [0.6.0.0] - Unreleased + +This version has not been published. Publication remains blocked until the combined wasmWasi +component-host gate, independent adversarial review, and Firefox/Safari hardware-passkey smoke +gates all pass with non-secret evidence. + +### Added + +- Passkey-first, storage-neutral Kotlin Multiplatform identity authority for JVM, wasmJs, and + wasmWasi, with WebAuthn registration, discoverable authentication, step-up, opaque rotating + sessions, recovery codes, and administrative recovery. +- Organization-scoped memberships and capabilities, invitations, RFC 8628 CLI device + authorization, rotating device tokens, and scoped service identities. +- Optional PostgreSQL, Firestore, Summon, OIDC, SAML, and SCIM modules plus a non-published + cross-target identity testkit. +- OpenSSL 3-backed WASI crypto host, strict startup self-tests, storage conformance suites, + browser E2E suites, and Seen FEL-634 consumer contract fixtures. +- JVM CLI device authorization with `auth login`, `whoami`, organization selection, and logout; + persisted credentials use only macOS Keychain, Windows DPAPI, or Linux Secret Service. +- Invite-only production registration and a single-use deployment bootstrap secret for creating + the first owner and organization. + +### Changed + +- `aether-auth` is completely replaced by the `/identity/v1` passkey authority. This is a + compile-breaking release; see `docs/migrations/0.6-passkey-identity.md`. +- Kotlin and the dependency stack are upgraded for Summon `0.7.0.2`; the CLI and example are now + compiled from real Kotlin source sets and CI rejects expected tasks that report `NO-SOURCE`. + +### Known release gates + +- The Kotlin `2.3.x` wasmWasi artifact is still a Preview1 core module. The WIT contract, guest + capability checks, and OpenSSL 3 native host primitives exist, but the combined component-model + binding and real `wasi:http` host/guest CI runner do not. The wasmWasi authority artifact must not + be published as production-ready until that gate passes. +- The independent adversarial review and manual Firefox/Safari hardware-passkey smoke checklist + have not yet been recorded as passing release evidence. + +### Removed + +- Password authentication, identity JWT fallback, legacy identity sessions, ActiveRecord identity + persistence, and global identity groups/permissions. Generic `aether-core` authentication + facilities remain available to unrelated applications. + ## [0.5.1.0] - 2026-01-16 ### Added diff --git a/README.md b/README.md index a31f48f..aa34186 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A Django-like Kotlin Multiplatform framework that runs on JVM (Vert.x + Virtual Threads) and Wasm (Cloudflare/Browser). +> **Release status:** `0.6.0.0` is unreleased and is not available from Maven Central. Its target +> coordinates below are illustrative until the combined wasmWasi host, adversarial-review, and +> manual hardware-passkey release gates pass. Use this source checkout for pre-release evaluation. + ## Architecture Aether is designed to be a "colorless" framework that abstracts away the differences between JVM and Wasm platforms, enabling developers to write their application logic once and deploy it anywhere. @@ -17,7 +21,7 @@ Aether is designed to be a "colorless" framework that abstracts away the differe - **Transport Abstraction**: Pluggable network layer allowing alternative transport implementations - **Session Management**: Secure, configurable session handling with multiple storage backends - **CSRF Protection**: Built-in cross-site request forgery protection middleware -- **Authentication**: Pluggable authentication with Basic, Bearer, JWT, API Key, and Form providers +- **Identity**: Passkey-first people, organizations, opaque sessions, CLI device flow, service identities, OIDC, SAML, and SCIM - **File Uploads**: Multipart form-data parsing with validation and streaming support - **WebSocket Support**: Full-duplex WebSocket communication with DSL-based configuration - **KSP Migrations**: Kotlin Symbol Processing for automatic database schema migration generation @@ -31,9 +35,9 @@ kotlin { jvm() sourceSets { commonMain.dependencies { - implementation("codes.yousef.aether:aether-core:0.4.0") - implementation("codes.yousef.aether:aether-web:0.4.0") - implementation("codes.yousef.aether:aether-db:0.4.0") + implementation("codes.yousef.aether:aether-core:0.6.0.0") + implementation("codes.yousef.aether:aether-web:0.6.0.0") + implementation("codes.yousef.aether:aether-db:0.6.0.0") } } } @@ -84,6 +88,8 @@ aether/ ├── aether-ui/ # UI rendering (SSR + CBOR) ├── aether-net/ # Network transport abstraction ├── aether-ksp/ # KSP-based migration generation +├── aether-auth/ # Storage-neutral passkey-first identity engine +├── aether-auth-*/ # Optional storage, UI, OIDC, SAML, and SCIM adapters ├── aether-plugin/ # Gradle plugin ├── aether-cli/ # Command-line tools ├── example-app/ # Example application @@ -95,28 +101,22 @@ aether/ ### Prerequisites - JDK 21 or later -- Kotlin 2.1.0 or later -- PostgreSQL (for JVM deployment) +- Kotlin 2.3.21 or later ### Running the Example App -1. Start a PostgreSQL database: -```bash -docker run -d \ - --name aether-postgres \ - -e POSTGRES_USER=aether \ - -e POSTGRES_PASSWORD=aether \ - -e POSTGRES_DB=aether_dev \ - -p 5432:5432 \ - postgres:16-alpine -``` +1. Build and run the KMP passkey identity example (JVM SSR plus wasmJs hydration): -2. Build and run the example application: ```bash -./gradlew :example-app:run +AETHER_IDENTITY_BOOTSTRAP_SECRET=a-development-secret-at-least-16-chars \ + ./gradlew :example-app:run ``` -3. Open your browser to `http://localhost:8080` +2. Open `http://localhost:8080/identity`. + +The example keeps the authority/storage host seam explicit. Production deployment selects either +the PostgreSQL 16 or Firestore identity adapter and follows the [identity deployment +guide](docs/identity/deployment.md). ### Running Tests @@ -293,6 +293,10 @@ DatabaseDriverRegistry.initialize(driver) - CBOR-based UI tree serialization - Future-proof architecture for emerging web platforms +The generic target is available for experimentation, but the `0.6.0.0` Identity authority is not +production-ready on wasmWasi until the combined Kotlin guest, WIT OpenSSL crypto host, and +`wasi:http` release gate passes. See the [identity deployment guide](docs/identity/deployment.md#release-verification). + ## Transport Abstraction The framework includes a pluggable network abstraction layer (`:aether-net`) that decouples application logic from the underlying transport mechanism. This enables: @@ -306,7 +310,11 @@ The transport layer provides a clean interface that can be implemented for diffe ## Security Features -### Session Management +### Generic `aether-core` Session Management + +This state-bag session API is for unrelated applications. Aether Identity does not use it: identity +sessions are opaque, rotated credentials in the `__Host-aether_session` cookie and are governed by +the [identity session policy](docs/identity/security.md#session-and-token-theft). ```kotlin val pipeline = Pipeline().apply { @@ -326,7 +334,11 @@ val user = session["user"] exchange.invalidateSession() ``` -### CSRF Protection +### Generic `aether-core` CSRF Protection + +The form-token helper below belongs to generic application forms. Aether Identity requires its +session-bound header token plus exact `Origin` validation and rejects form/query-string CSRF +tokens; see [identity CSRF policy](docs/identity/security.md#csrf-and-cross-origin-requests). ```kotlin val pipeline = Pipeline().apply { @@ -345,7 +357,12 @@ exchange.render { } ``` -### Authentication +### Generic `aether-core` authentication (unrelated applications only) + +The following providers belong to the generic core protocol layer. They are not Aether Identity, +must not be mounted on `/identity/v1`, and cannot establish an identity user, organization, +membership, capability, or step-up assurance. Identity applications use the passkey authority +documented in [Passkey-first identity](docs/identity/README.md). ```kotlin val authConfig = AuthenticationConfig().apply { @@ -447,28 +464,12 @@ val migration = migration("002", "Add users table") { ## Production Deployment -### Docker - -```bash -# Build image -docker build -t aether-app:latest -f docs/deployment/Dockerfile . - -# Run with Docker Compose -cd docs/deployment -docker compose up -d -``` - -### Kubernetes - -```bash -# Apply all manifests -kubectl apply -f docs/deployment/kubernetes/ - -# Check status -kubectl -n aether get pods -``` - -See [Deployment Guide](docs/deployment/DEPLOYMENT.md) for detailed instructions on AWS, GCP, Azure, and DigitalOcean deployments. +Aether Identity deployments must follow the dedicated [identity deployment +guide](docs/identity/deployment.md). It is the only deployment guide in this repository that applies +to the passkey authority. The old sample Docker, Compose, Nginx, SQL, and Kubernetes files used +nonexistent example distribution tasks and legacy JWT/session settings; they were removed and a +non-runnable tombstone remains in their place. Package unrelated `aether-core` applications from +their own application build and deployment model. ## Contributing diff --git a/aether-admin/gradle.lockfile b/aether-admin/gradle.lockfile new file mode 100644 index 0000000..ee7c2f1 --- /dev/null +++ b/aether-admin/gradle.lockfile @@ -0,0 +1,90 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.auth0:java-jwt:4.4.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.zaxxer:HikariCP:6.2.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.netty:netty-buffer:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-auth-common:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-bridge-common:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-pg-client:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-sql-client:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-web-common:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-web:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-wasi:2.3.21=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.30.0-beta=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-wasi:1.10.2=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-wasi:0.7.1=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains:annotations:13.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmTestCompileClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.postgresql:postgresql:42.7.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,wasmWasiMainImplementationDependenciesMetadata,wasmWasiTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-firestore/build.gradle.kts b/aether-auth-firestore/build.gradle.kts new file mode 100644 index 0000000..8cf87d4 --- /dev/null +++ b/aether-auth-firestore/build.gradle.kts @@ -0,0 +1,50 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +import org.gradle.api.tasks.testing.Test + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + testRuns["test"].executionTask.configure { useJUnitPlatform() } + } + wasmJs { nodejs() } + wasmWasi { nodejs() } + + sourceSets { + commonMain.dependencies { + api(project(":aether-auth")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(project(":aether-auth-testkit")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlin.test) + } + } +} + +// A separate, never-up-to-date execution of the JVM test compilation. The test itself is skipped +// during ordinary `jvmTest`; this task turns on the release gate and fails if the real emulator is +// absent, unreachable, or violates the adapter's atomicity contract. +val jvmTestTask = tasks.named("jvmTest") +tasks.register("firestoreEmulatorTest") { + group = "verification" + description = "Runs the identity store race suite through the real Firestore REST emulator" + dependsOn("jvmTestClasses") + testClassesDirs = jvmTestTask.get().testClassesDirs + classpath = jvmTestTask.get().classpath + useJUnitPlatform() + filter { + includeTestsMatching( + "codes.yousef.aether.auth.firestore.FirestoreIdentityStoreEmulatorTest" + ) + } + systemProperty("aether.firestore.emulator.gate", "true") + outputs.upToDateWhen { false } +} diff --git a/aether-auth-firestore/gradle.lockfile b/aether-auth-firestore/gradle.lockfile new file mode 100644 index 0000000..f0d52a6 --- /dev/null +++ b/aether-auth-firestore/gradle.lockfile @@ -0,0 +1,99 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.fasterxml.jackson.core:jackson-core:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.netty:netty-buffer:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jvmTestCompileClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-wasi:2.3.21=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit5:2.3.21=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-wasi:2.3.21=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.26.1=wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.30.0-beta=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-wasi:1.10.2=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-wasi:1.10.2=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-wasi:0.7.1=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.10.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.10.1=jvmTestRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.junit.platform:junit-platform-engine:1.10.1=jvmTestRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.10.1=jvmTestRuntimeClasspath +org.junit:junit-bom:5.10.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,wasmWasiMainImplementationDependenciesMetadata,wasmWasiTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-firestore/run-emulator-gate.sh b/aether-auth-firestore/run-emulator-gate.sh new file mode 100755 index 0000000..8c98989 --- /dev/null +++ b/aether-auth-firestore/run-emulator-gate.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EMULATOR_PORT="${AETHER_FIRESTORE_EMULATOR_PORT:-8085}" +PROJECT_ID="${AETHER_FIRESTORE_EMULATOR_PROJECT_ID:-aether-identity-emulator-gate}" +EMULATOR_LOG="" +EMULATOR_PID="" + +cleanup() { + if [[ -n "$EMULATOR_PID" ]] && kill -0 "$EMULATOR_PID" 2>/dev/null; then + kill "$EMULATOR_PID" 2>/dev/null || true + wait "$EMULATOR_PID" 2>/dev/null || true + fi + if [[ -n "$EMULATOR_LOG" ]]; then + rm -f "$EMULATOR_LOG" + fi +} +trap cleanup EXIT INT TERM + +case "$EMULATOR_PORT" in + ''|*[!0-9]*) echo "AETHER_FIRESTORE_EMULATOR_PORT must be numeric" >&2; exit 2 ;; +esac +if (( EMULATOR_PORT < 1 || EMULATOR_PORT > 65535 )); then + echo "AETHER_FIRESTORE_EMULATOR_PORT must be between 1 and 65535" >&2 + exit 2 +fi +case "$PROJECT_ID" in + aether-identity-emulator-*) ;; + *) echo "Refusing to reset a project outside the aether-identity-emulator-* test prefix" >&2; exit 2 ;; +esac +if [[ "$PROJECT_ID" == *prod* ]]; then + echo "Refusing to use a production-like project ID for the emulator gate" >&2 + exit 2 +fi + +command -v gcloud >/dev/null 2>&1 || { + echo "gcloud is required; install the cloud-firestore-emulator component" >&2 + exit 1 +} +command -v curl >/dev/null 2>&1 || { + echo "curl is required for emulator readiness checks" >&2 + exit 1 +} + +EMULATOR_LOG="$(mktemp -t aether-firestore-emulator.XXXXXX.log)" +gcloud emulators firestore start \ + --host-port="127.0.0.1:$EMULATOR_PORT" \ + --project="$PROJECT_ID" \ + --quiet >"$EMULATOR_LOG" 2>&1 & +EMULATOR_PID=$! + +RESET_URL="http://127.0.0.1:$EMULATOR_PORT/emulator/v1/projects/$PROJECT_ID/databases/(default)/documents" +READY=false +for _ in $(seq 1 120); do + if ! kill -0 "$EMULATOR_PID" 2>/dev/null; then + echo "Firestore emulator exited during startup" >&2 + sed -n '1,200p' "$EMULATOR_LOG" >&2 + exit 1 + fi + if curl --fail --silent --show-error --request DELETE "$RESET_URL" >/dev/null 2>&1; then + READY=true + break + fi + sleep 0.5 +done +if [[ "$READY" != true ]]; then + echo "Firestore emulator did not become ready within 60 seconds" >&2 + sed -n '1,200p' "$EMULATOR_LOG" >&2 + exit 1 +fi + +env \ + FIRESTORE_EMULATOR_HOST="127.0.0.1:$EMULATOR_PORT" \ + AETHER_FIRESTORE_EMULATOR_PROJECT_ID="$PROJECT_ID" \ + "$ROOT_DIR/gradlew" \ + :aether-auth-firestore:firestoreEmulatorTest \ + --dependency-verification=strict \ + --no-daemon \ + --stacktrace diff --git a/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreFailures.kt b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreFailures.kt new file mode 100644 index 0000000..d97a525 --- /dev/null +++ b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreFailures.kt @@ -0,0 +1,42 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.IdentityStoreError +import codes.yousef.aether.auth.IdentityStoreErrorCode + +internal class FirestoreStoreException( + val safeError: IdentityStoreError, + val transactionRetryable: Boolean = false +) : RuntimeException("Firestore identity operation failed") { + override fun toString(): String = + "FirestoreStoreException(code=${safeError.code}, retryable=${safeError.retryable})" +} + +internal object FirestoreFailureMapper { + fun fromProvider(status: String?, httpStatus: Int? = null): IdentityStoreError { + val normalized = status?.trim()?.uppercase() + return when { + normalized == "ALREADY_EXISTS" -> failure(IdentityStoreErrorCode.ALREADY_EXISTS) + normalized == "NOT_FOUND" -> failure(IdentityStoreErrorCode.NOT_FOUND) + normalized == "ABORTED" || normalized == "FAILED_PRECONDITION" -> versionConflict() + normalized == "RESOURCE_EXHAUSTED" || normalized == "UNAVAILABLE" || + normalized == "DEADLINE_EXCEEDED" -> unavailable() + normalized == "PERMISSION_DENIED" || normalized == "UNAUTHENTICATED" || + normalized == "INVALID_ARGUMENT" -> internal() + httpStatus == 404 -> failure(IdentityStoreErrorCode.NOT_FOUND) + httpStatus == 409 || httpStatus == 412 -> versionConflict() + httpStatus == 429 || (httpStatus != null && httpStatus >= 500) -> unavailable() + else -> internal() + } + } + + fun versionConflict(): IdentityStoreError = + failure(IdentityStoreErrorCode.VERSION_CONFLICT, retryable = true) + + fun unavailable(): IdentityStoreError = + failure(IdentityStoreErrorCode.UNAVAILABLE, retryable = true) + + fun internal(): IdentityStoreError = failure(IdentityStoreErrorCode.INTERNAL) + + private fun failure(code: IdentityStoreErrorCode, retryable: Boolean = false): IdentityStoreError = + IdentityStoreError(code, retryable) +} diff --git a/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityConfig.kt b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityConfig.kt new file mode 100644 index 0000000..0a97e9e --- /dev/null +++ b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityConfig.kt @@ -0,0 +1,87 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.IdentityConfig +import codes.yousef.aether.auth.IdentityEnvironment +import kotlinx.serialization.Serializable + +/** Configuration for the Firestore REST adapter. No credential material is stored here. */ +@Serializable +data class FirestoreIdentityConfig( + val environment: IdentityEnvironment, + val namespace: String, + val projectId: String, + val databaseId: String = "(default)", + val apiBaseUrl: String = DEFAULT_API_BASE_URL, + val maximumRequestBytes: Int = DEFAULT_MAXIMUM_REQUEST_BYTES, + val maximumResponseBytes: Int = DEFAULT_MAXIMUM_RESPONSE_BYTES, + val maximumTransactionAttempts: Int = DEFAULT_MAXIMUM_TRANSACTION_ATTEMPTS +) { + init { + require(PROJECT_ID.matches(projectId)) { "Invalid Firestore project ID" } + require(DATABASE_ID.matches(databaseId)) { "Invalid Firestore database ID" } + require(NAMESPACE.matches(namespace)) { "Invalid Firestore identity namespace" } + require(environment.wireName in namespace) { + "Firestore identity namespace must contain the environment name" + } + require(maximumRequestBytes in 1_024..MAXIMUM_WIRE_BYTES) { "Invalid maximum Firestore request size" } + require(maximumResponseBytes in 1_024..MAXIMUM_WIRE_BYTES) { "Invalid maximum Firestore response size" } + require(maximumTransactionAttempts in 1..10) { "Invalid maximum Firestore transaction attempts" } + requireValidFirestoreBaseUrl(apiBaseUrl) + if (environment in setOf(IdentityEnvironment.STAGING, IdentityEnvironment.PRODUCTION)) { + require(apiBaseUrl.startsWith("https://")) { "Staging and production Firestore must use HTTPS" } + } + } + + val normalizedApiBaseUrl: String get() = apiBaseUrl.trimEnd('/') + + /** Fully qualified Firestore documents root, without a trailing slash. */ + val documentsRoot: String + get() = "$normalizedApiBaseUrl/projects/$projectId/databases/$databaseId/documents" + + /** Namespace document below which all identity collections are stored. */ + val namespaceDocument: String + get() = "aetherIdentity/$namespace" + + /** Database-global singleton marker. It is deliberately outside [namespaceDocument]. */ + val environmentMarkerDocument: String + get() = "aetherIdentityEnvironment/current" + + override fun toString(): String = + "FirestoreIdentityConfig(environment=${environment.wireName}, namespace=$namespace, " + + "projectId=$projectId, databaseId=$databaseId, apiBaseUrl=)" + + companion object { + const val DEFAULT_API_BASE_URL: String = "https://firestore.googleapis.com/v1" + const val DEFAULT_MAXIMUM_REQUEST_BYTES: Int = 2 * 1_024 * 1_024 + const val DEFAULT_MAXIMUM_RESPONSE_BYTES: Int = 8 * 1_024 * 1_024 + const val DEFAULT_MAXIMUM_TRANSACTION_ATTEMPTS: Int = 5 + private const val MAXIMUM_WIRE_BYTES: Int = 16 * 1_024 * 1_024 + private val PROJECT_ID = Regex("[a-z][a-z0-9-]{4,61}[a-z0-9]") + private val DATABASE_ID = Regex("\\(default\\)|[a-z][a-z0-9_-]{0,62}") + private val NAMESPACE = Regex("[a-z][a-z0-9_-]{0,62}") + + fun fromIdentityConfig( + identity: IdentityConfig, + projectId: String, + databaseId: String = "(default)", + apiBaseUrl: String = DEFAULT_API_BASE_URL + ): FirestoreIdentityConfig = FirestoreIdentityConfig( + environment = identity.environment, + namespace = identity.storageNamespace, + projectId = projectId, + databaseId = databaseId, + apiBaseUrl = apiBaseUrl + ) + } +} + +private fun requireValidFirestoreBaseUrl(value: String) { + require(value == value.trim() && value.isNotEmpty()) { "Invalid Firestore API base URL" } + require('?' !in value && '#' !in value && '@' !in value) { + "Firestore API base URL must not contain query, fragment, or user info" + } + val secure = value.startsWith("https://") + val loopback = value.startsWith("http://localhost") || + value.startsWith("http://127.0.0.1") || value.startsWith("http://[::1]") + require(secure || loopback) { "Firestore must use HTTPS except for an exact loopback emulator" } +} diff --git a/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStore.kt b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStore.kt new file mode 100644 index 0000000..b86c0d8 --- /dev/null +++ b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStore.kt @@ -0,0 +1,2962 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.* +import kotlin.time.Instant +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Firestore implementation of [IdentityStore]. + * + * Command methods execute as Firestore read/write transactions. Every entity mutation, uniqueness + * claim, replay/idempotency receipt, and audit record is committed together with update-time or + * exists preconditions. Call [initialize] at startup; the store fails closed until the environment + * marker matches [FirestoreIdentityConfig]. + */ +class FirestoreIdentityStore internal constructor( + private val config: FirestoreIdentityConfig, + private val runtime: IdentityRuntime, + private val transport: FirestoreDocumentTransport, + private val json: Json = defaultFirestoreJson() +) : IdentityStore { + constructor( + config: FirestoreIdentityConfig, + runtime: IdentityRuntime, + accessTokens: FirestoreAccessTokenProvider, + json: Json = defaultFirestoreJson() + ) : this(config, runtime, FirestoreRestTransport(config, runtime, accessTokens, json), json) + + private val initializationMutex = Mutex() + private var initialized = false + + /** Verifies the pre-provisioned environment marker. Missing and mismatched markers fail closed. */ + suspend fun initialize(): StoreResult = initializationMutex.withLock { + if (initialized) return@withLock StoreResult.Success(Unit) + when (val verification = verifyEnvironmentMarker()) { + is StoreResult.Failure -> verification + is StoreResult.Success -> { + initialized = true + verification + } + } + } + + /** Explicit deployment action. Runtime startup never creates or overwrites this marker. */ + suspend fun provisionEnvironmentMarker(): StoreResult = try { + val existing = transport.get(environmentMarkerName()) + if (existing != null) { + if (environmentMarkerMatches(existing)) StoreResult.Success(Unit) + else failure(IdentityStoreErrorCode.INTERNAL) + } else { + transport.commit( + transaction = null, + writes = listOf( + FirestoreWrite( + update = FirestoreDocument( + name = environmentMarkerName(), + fields = mapOf( + FIELD_ENVIRONMENT to stringValue(config.environment.wireName), + FIELD_NAMESPACE to stringValue(config.namespace), + FIELD_SCHEMA_VERSION to + integerValue(FIRESTORE_ENVIRONMENT_MARKER_SCHEMA_VERSION.toLong()) + ) + ), + currentDocument = FirestorePrecondition(exists = false) + ) + ) + ) + StoreResult.Success(Unit) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: FirestoreStoreException) { + if (failure.safeError.code == IdentityStoreErrorCode.VERSION_CONFLICT) { + verifyEnvironmentMarker() + } else { + StoreResult.Failure(failure.safeError) + } + } catch (_: Throwable) { + StoreResult.Failure(FirestoreFailureMapper.internal()) + } + + private suspend fun verifyEnvironmentMarker(): StoreResult = try { + val marker = transport.get(environmentMarkerName()) + ?: return failure(IdentityStoreErrorCode.NOT_FOUND) + if (environmentMarkerMatches(marker)) StoreResult.Success(Unit) + else failure(IdentityStoreErrorCode.INTERNAL) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: FirestoreStoreException) { + StoreResult.Failure(failure.safeError) + } catch (_: Throwable) { + StoreResult.Failure(FirestoreFailureMapper.internal()) + } + + private fun environmentMarkerMatches(marker: FirestoreDocument): Boolean = + marker.fields[FIELD_ENVIRONMENT]?.stringValue == config.environment.wireName && + marker.fields[FIELD_NAMESPACE]?.stringValue == config.namespace && + marker.fields[FIELD_SCHEMA_VERSION]?.integerValue?.toIntOrNull() == + FIRESTORE_ENVIRONMENT_MARKER_SCHEMA_VERSION + + private fun environmentMarkerName(): String = + "projects/${config.projectId}/databases/${config.databaseId}/documents/${config.environmentMarkerDocument}" + + override suspend fun findUser(id: UserId): StoreResult = readEntity(COLLECTION_USERS, id.value, User.serializer()) + + override suspend fun findUserByEmail(email: EmailAddress): StoreResult = readUniqueEntity( + uniqueKind = UNIQUE_EMAIL, + uniqueValue = normalizeEmail(email), + serializer = User.serializer() + ) + + override suspend fun findCredential(id: CredentialId): StoreResult = + readEntity(COLLECTION_CREDENTIALS, id.value, Credential.serializer()) + + override suspend fun findCredentialByWebAuthnId(id: WebAuthnCredentialId): StoreResult = + readUniqueEntity(UNIQUE_WEBAUTHN_ID, id.encoded, Credential.serializer()) + + override suspend fun listCredentialsForUser(userId: UserId): StoreResult> = + queryEntities(COLLECTION_CREDENTIALS, Credential.serializer(), FIELD_USER_ID, userId.value) { it.id.value } + + override suspend fun findSession(id: SessionId): StoreResult = + readEntity(COLLECTION_SESSIONS, id.value, IdentitySession.serializer()) + + override suspend fun listSessionsForUser(userId: UserId): StoreResult> = + queryEntities(COLLECTION_SESSIONS, IdentitySession.serializer(), FIELD_USER_ID, userId.value) { it.id.value } + + override suspend fun findOrganization(id: OrganizationId): StoreResult = + readEntity(COLLECTION_ORGANIZATIONS, id.value, Organization.serializer()) + + override suspend fun findOrganizationBySlug(slug: String): StoreResult = + readUniqueEntity(UNIQUE_ORGANIZATION_SLUG, slug, Organization.serializer()) + + override suspend fun listOrganizationsForUser(userId: UserId): StoreResult> { + requireInitialized() ?: return StoreResult.Failure(FirestoreFailureMapper.unavailable()) + return safeRead { + transport.runQuery(parentName(), equalityQuery(COLLECTION_MEMBERSHIPS, FIELD_USER_ID, userId.value)) + .map { it.decode(Membership.serializer()) } + .filter { it.state == MembershipState.ACTIVE } + .mapNotNull { membership -> + transport.get(name(COLLECTION_ORGANIZATIONS, membership.organizationId.value)) + ?.decode(Organization.serializer()) + } + .filter { it.state == OrganizationState.ACTIVE } + .distinctBy { it.id } + .sortedBy { it.id.value } + } + } + + override suspend fun findMembership(id: MembershipId): StoreResult = + readEntity(COLLECTION_MEMBERSHIPS, id.value, Membership.serializer()) + + override suspend fun findMembershipForUser( + userId: UserId, + organizationId: OrganizationId + ): StoreResult = readUniqueEntity( + uniqueKind = UNIQUE_MEMBERSHIP, + uniqueValue = membershipUniqueValue(userId, organizationId), + serializer = Membership.serializer() + ) + + override suspend fun listMembershipsForOrganization( + organizationId: OrganizationId + ): StoreResult> = queryEntities( + COLLECTION_MEMBERSHIPS, + Membership.serializer(), + FIELD_ORGANIZATION_ID, + organizationId.value + ) { it.id.value } + + override suspend fun findInvitation(id: InvitationId): StoreResult = + readEntity(COLLECTION_INVITATIONS, id.value, Invitation.serializer()) + + override suspend fun findInvitationByTokenDigest(digest: SecretDigest): StoreResult = + readUniqueEntity(UNIQUE_INVITATION_DIGEST, digestUniqueValue(digest), Invitation.serializer()) + + override suspend fun listInvitationsForOrganization( + organizationId: OrganizationId + ): StoreResult> = queryEntities( + COLLECTION_INVITATIONS, + Invitation.serializer(), + FIELD_ORGANIZATION_ID, + organizationId.value + ) { it.id.value } + + override suspend fun findServiceIdentity(id: ServiceIdentityId): StoreResult = + readEntity(COLLECTION_SERVICE_IDENTITIES, id.value, ServiceIdentity.serializer()) + + override suspend fun listServiceIdentitiesForOrganization( + organizationId: OrganizationId + ): StoreResult> = queryEntities( + COLLECTION_SERVICE_IDENTITIES, + ServiceIdentity.serializer(), + FIELD_ORGANIZATION_ID, + organizationId.value + ) { it.id.value } + + override suspend fun findServiceCredentialByPrefix(publicPrefix: String): StoreResult = + readUniqueEntity(UNIQUE_SERVICE_PREFIX, publicPrefix, ServiceCredential.serializer()) + + override suspend fun listServiceCredentialsForIdentity( + serviceIdentityId: ServiceIdentityId + ): StoreResult> = queryEntities( + COLLECTION_SERVICE_CREDENTIALS, + ServiceCredential.serializer(), + FIELD_SERVICE_IDENTITY_ID, + serviceIdentityId.value + ) { it.id.value } + + override suspend fun findExternalIdentity( + provider: String, + subject: ExternalSubject + ): StoreResult = readUniqueEntity( + UNIQUE_EXTERNAL_IDENTITY, + externalUniqueValue(provider, subject), + ExternalIdentity.serializer() + ) + + override suspend fun findFederationProviderControl( + organizationId: OrganizationId, + providerId: String + ): StoreResult = readUniqueEntity( + UNIQUE_FEDERATION_PROVIDER_ROUTE, + federationProviderRouteUniqueValue(organizationId, providerId), + FederationProviderControl.serializer() + ) + + override suspend fun findFederationProviderControlByStorageKey( + storageKey: String + ): StoreResult = readUniqueEntity( + UNIQUE_FEDERATION_PROVIDER_STORAGE_KEY, + storageKey, + FederationProviderControl.serializer() + ) + + override suspend fun findScimGroup( + provider: String, + organizationId: OrganizationId, + id: String + ): StoreResult = when (val result = readEntity(COLLECTION_SCIM_GROUPS, id, ScimGroup.serializer())) { + is StoreResult.Failure -> result + is StoreResult.Success -> StoreResult.Success( + result.value?.takeIf { it.provider == provider && it.organizationId == organizationId } + ) + } + + override suspend fun findChallenge(id: ChallengeId): StoreResult = + readEntity(COLLECTION_CHALLENGES, id.value, Challenge.serializer()) + + override suspend fun findRecoveryCodeBySelector(publicSelector: String): StoreResult = + readUniqueEntity(UNIQUE_RECOVERY_SELECTOR, publicSelector, RecoveryCode.serializer()) + + override suspend fun listRecoveryCodesForUser(userId: UserId): StoreResult> = queryEntities( + COLLECTION_RECOVERY_CODES, + RecoveryCode.serializer(), + FIELD_USER_ID, + userId.value + ) { it.id.value } + + override suspend fun findDeviceGrant(id: DeviceGrantId): StoreResult = + readEntity(COLLECTION_DEVICE_GRANTS, id.value, DeviceGrant.serializer()) + + override suspend fun findDeviceGrantByDeviceCodeDigest(digest: SecretDigest): StoreResult = + readUniqueEntity(UNIQUE_DEVICE_CODE, digestUniqueValue(digest), DeviceGrant.serializer()) + + override suspend fun findDeviceGrantByUserCodeDigest(digest: SecretDigest): StoreResult = + readUniqueEntity(UNIQUE_USER_CODE, digestUniqueValue(digest), DeviceGrant.serializer()) + + override suspend fun findDeviceTokenFamily(id: DeviceTokenFamilyId): StoreResult = + readEntity(COLLECTION_DEVICE_TOKEN_FAMILIES, id.value, DeviceTokenFamily.serializer()) + + override suspend fun findDeviceAccessTokenBySelector(publicSelector: String): StoreResult = + readUniqueEntity(UNIQUE_DEVICE_ACCESS_SELECTOR, publicSelector, DeviceAccessToken.serializer()) + + override suspend fun findDeviceRefreshTokenBySelector(publicSelector: String): StoreResult = + readUniqueEntity(UNIQUE_DEVICE_REFRESH_SELECTOR, publicSelector, DeviceRefreshToken.serializer()) + + override suspend fun listAuditEventsForOrganization( + request: OrganizationAuditEventPageRequest + ): StoreResult { + requireInitialized() ?: return StoreResult.Failure(FirestoreFailureMapper.unavailable()) + return safeRead { + val selected = transport.runQuery(parentName(), auditPageQuery(request)) + .map { it.decode(AuditEvent.serializer()) } + val events = selected.take(request.limit) + OrganizationAuditEventPage( + organizationId = request.organizationId, + events = events, + nextCursor = events.lastOrNull()?.toOrganizationAuditCursor().takeIf { + selected.size > request.limit + } + ) + } + } + + override suspend fun purgeAuditEvents( + command: PurgeAuditEventsCommand + ): StoreResult = atomic { + val selected = auditEventsBefore(command) + selected.take(command.maximumEvents).forEach(::delete) + PurgeAuditEventsCommit( + deletedCount = minOf(selected.size, command.maximumEvents), + hasMore = selected.size > command.maximumEvents + ) + } + + override suspend fun createChallenge(command: CreateChallengeCommand): StoreResult = atomic { + requireAbsent(COLLECTION_CHALLENGES, command.challenge.id.value) + requireChallengeFederationLease(command.challenge, command.federationProviderLease) + claimUnique(UNIQUE_CHALLENGE_DIGEST, digestUniqueValue(command.challenge.challengeDigest), COLLECTION_CHALLENGES, command.challenge.id.value) + command.auditEvent?.let { requireAuditAvailable(it) } + create(COLLECTION_CHALLENGES, command.challenge.id.value, command.challenge, Challenge.serializer(), challengeFields(command.challenge)) + command.auditEvent?.let { appendAudit(it) } + command.challenge + } + + override suspend fun consumeChallenge(command: ConsumeChallengeCommand): StoreResult = atomic { + val existing = if (command.terminalState == ChallengeState.EXPIRED) { + val candidate = requireEntity(COLLECTION_CHALLENGES, command.challengeId.value, Challenge.serializer()) + if (candidate.value.state != ChallengeState.PENDING || command.consumedAt < candidate.value.expiresAt) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + requireVersion(candidate.value.version, command.expectedVersion) + candidate + } else { + requireChallenge(command.challengeId, command.expectedVersion, command.consumedAt) + } + requireChallengeFederationLease(existing.value, command.federationProviderLease) + command.auditEvent?.let { requireAuditAvailable(it) } + val replacement = existing.value.copy( + state = command.terminalState, + attemptCount = existing.value.attemptCount + if (command.terminalState == ChallengeState.FAILED) 1 else 0, + version = existing.value.version + 1, + consumedAt = command.consumedAt + ) + update(existing, replacement, Challenge.serializer(), challengeFields(replacement)) + command.auditEvent?.let { appendAudit(it) } + replacement + } + + override suspend fun appendAuditEvent(event: AuditEvent): StoreResult = atomic { + requireAuditAvailable(event) + appendAudit(event) + event + } + + override suspend fun bootstrapIdentity( + command: BootstrapIdentityCommand + ): StoreResult = atomic { + requireAbsent(COLLECTION_BOOTSTRAP, DOCUMENT_CURRENT) + val identityCollections = listOf( + COLLECTION_USERS, + COLLECTION_CREDENTIALS, + COLLECTION_SESSIONS, + COLLECTION_ORGANIZATIONS, + COLLECTION_MEMBERSHIPS, + COLLECTION_INVITATIONS, + COLLECTION_SERVICE_IDENTITIES, + COLLECTION_SERVICE_CREDENTIALS, + COLLECTION_EXTERNAL_IDENTITIES, + COLLECTION_FEDERATION_PROVIDER_CONTROLS, + COLLECTION_CHALLENGES, + COLLECTION_RECOVERY_CODES, + COLLECTION_DEVICE_GRANTS, + COLLECTION_DEVICE_TOKEN_FAMILIES, + COLLECTION_DEVICE_ACCESS_TOKENS, + COLLECTION_DEVICE_REFRESH_TOKENS + ) + if (identityCollections.any { hasAny(it) }) abort(IdentityStoreErrorCode.ALREADY_EXISTS) + command.user.primaryEmail?.let { + claimUnique(UNIQUE_EMAIL, normalizeEmail(it), COLLECTION_USERS, command.user.id.value) + } + claimUnique( + UNIQUE_ORGANIZATION_SLUG, + command.organization.slug, + COLLECTION_ORGANIZATIONS, + command.organization.id.value + ) + claimUnique( + UNIQUE_MEMBERSHIP, + membershipUniqueValue(command.user.id, command.organization.id), + COLLECTION_MEMBERSHIPS, + command.ownerMembership.id.value + ) + requireNewSession(command.enrollmentSession, command.user) + requireAuditAvailable(command.auditEvent) + create( + COLLECTION_BOOTSTRAP, + DOCUMENT_CURRENT, + BootstrapReceipt(command.bootstrapSecretDigest, command.auditEvent.occurredAt), + BootstrapReceipt.serializer() + ) + create( + COLLECTION_USERS, + command.user.id.value, + command.user, + User.serializer(), + userFields(command.user) + ) + create( + COLLECTION_ORGANIZATIONS, + command.organization.id.value, + command.organization, + Organization.serializer(), + organizationFields(command.organization) + ) + create( + COLLECTION_MEMBERSHIPS, + command.ownerMembership.id.value, + command.ownerMembership, + Membership.serializer(), + membershipFields(command.ownerMembership) + ) + createSessionValue(command.enrollmentSession) + appendAudit(command.auditEvent) + BootstrapIdentityCommit( + command.user, + command.organization, + command.ownerMembership, + command.enrollmentSession, + command.auditEvent + ) + } + + override suspend fun completeCredentialRegistration( + command: CompleteCredentialRegistrationCommand + ): StoreResult> = atomicWebAuthn( + command.challengeId, + command.expectedChallengeVersion, + command.auditEvent.occurredAt, + command.rejectionAuditEvent + ) { + val completedAt = command.auditEvent.occurredAt + val challenge = requireChallenge(command.challengeId, command.expectedChallengeVersion, completedAt) + challenge.value.userId?.let { if (it != command.credential.userId) abort(IdentityStoreErrorCode.INVALID_TRANSITION) } + requireAbsent(COLLECTION_CREDENTIALS, command.credential.id.value) + claimUnique( + UNIQUE_WEBAUTHN_ID, + command.credential.webAuthnId.encoded, + COLLECTION_CREDENTIALS, + command.credential.id.value + ) + requireAuditAvailable(command.auditEvent) + + val replacementUser = command.user + if (replacementUser == null) { + requireEntity(COLLECTION_USERS, command.credential.userId.value, User.serializer()) + } else { + val expectedVersion = command.expectedUserVersion!! + if (expectedVersion == -1L) { + requireAbsent(COLLECTION_USERS, replacementUser.id.value) + if (replacementUser.version != 0L) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + replacementUser.primaryEmail?.let { + claimUnique(UNIQUE_EMAIL, normalizeEmail(it), COLLECTION_USERS, replacementUser.id.value) + } + create(COLLECTION_USERS, replacementUser.id.value, replacementUser, User.serializer(), userFields(replacementUser)) + } else { + val current = requireEntity(COLLECTION_USERS, replacementUser.id.value, User.serializer()) + requireVersion(current.value.version, expectedVersion) + if (replacementUser.version != expectedVersion + 1) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + replaceUserEmailClaim(current.value, replacementUser) + update(current, replacementUser, User.serializer(), userFields(replacementUser)) + } + } + + val consumed = consumeChallengeValue(challenge.value, completedAt) + update(challenge, consumed, Challenge.serializer(), challengeFields(consumed)) + create( + COLLECTION_CREDENTIALS, + command.credential.id.value, + command.credential, + Credential.serializer(), + credentialFields(command.credential) + ) + appendAudit(command.auditEvent) + CredentialRegistrationCommit(consumed, command.credential, replacementUser, command.auditEvent) + } + + override suspend fun completeCredentialAuthentication( + command: CompleteCredentialAuthenticationCommand + ): StoreResult> = atomicWebAuthn( + command.challengeId, + command.expectedChallengeVersion, + command.authenticatedAt, + command.rejectionAuditEvent + ) { + val challenge = requireChallenge(command.challengeId, command.expectedChallengeVersion, command.authenticatedAt) + val credential = requireEntity(COLLECTION_CREDENTIALS, command.credentialId.value, Credential.serializer()) + requireVersion(credential.value.version, command.expectedCredentialVersion) + if (credential.value.state != CredentialState.ACTIVE) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + if (credential.value.signCount != 0L && command.newSignCount != 0L && command.newSignCount <= credential.value.signCount) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + if (credential.value.backupEligible != command.backupEligible) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + challenge.value.userId?.let { if (it != credential.value.userId) abort(IdentityStoreErrorCode.INVALID_TRANSITION) } + + val user = requireEntity(COLLECTION_USERS, credential.value.userId.value, User.serializer()) + requireNewSession(command.session, user.value) + requireAuditAvailable(command.auditEvent) + val replaced = command.replacedSessionId?.let { id -> + val current = requireActiveSession( + id, + command.expectedReplacedSessionVersion!!, + command.authenticatedAt, + validateFederationProvider = true + ) + if (current.value.userId != credential.value.userId || command.session.rotatedFromId != current.value.id) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + current + } + val updatedCredential = credential.value.copy( + signCount = command.newSignCount, + backupEligible = command.backupEligible, + backedUp = command.backedUp, + version = credential.value.version + 1, + updatedAt = command.authenticatedAt, + lastUsedAt = command.authenticatedAt + ) + val consumed = consumeChallengeValue(challenge.value, command.authenticatedAt) + val rotated = replaced?.value?.let { rotateSessionValue(it, command.session.id) } + update(challenge, consumed, Challenge.serializer(), challengeFields(consumed)) + update(credential, updatedCredential, Credential.serializer(), credentialFields(updatedCredential)) + replaced?.let { update(it, requireNotNull(rotated), IdentitySession.serializer(), sessionFields(rotated)) } + createSessionValue(command.session) + appendAudit(command.auditEvent) + CredentialAuthenticationCommit(consumed, updatedCredential, command.session, rotated, command.auditEvent) + } + + override suspend fun quarantineCredentialAuthentication( + command: QuarantineCredentialAuthenticationCommand + ): StoreResult> = atomicWebAuthn( + command.challengeId, + command.expectedChallengeVersion, + command.detectedAt, + command.rejectionAuditEvent + ) { + val challenge = requireChallenge(command.challengeId, command.expectedChallengeVersion, command.detectedAt) + if (challenge.value.purpose != ChallengePurpose.WEBAUTHN_AUTHENTICATION && + challenge.value.purpose != ChallengePurpose.STEP_UP + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val credential = requireEntity(COLLECTION_CREDENTIALS, command.credentialId.value, Credential.serializer()) + requireVersion(credential.value.version, command.expectedCredentialVersion) + if (credential.value.state != CredentialState.ACTIVE || credential.value.signCount == 0L || + command.observedSignCount > credential.value.signCount + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + if (credential.value.backupEligible != command.backupEligible) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + challenge.value.userId?.let { if (it != credential.value.userId) abort(IdentityStoreErrorCode.INVALID_TRANSITION) } + requireAuditAvailable(command.auditEvent) + + val consumed = consumeChallengeValue(challenge.value, command.detectedAt) + val quarantined = credential.value.copy( + signCount = command.observedSignCount, + backupEligible = command.backupEligible, + backedUp = command.backedUp, + state = CredentialState.SUSPECTED_CLONE, + version = credential.value.version + 1, + updatedAt = command.detectedAt, + lastUsedAt = command.detectedAt, + revocationReasonCode = "signature_counter_anomaly" + ) + update(challenge, consumed, Challenge.serializer(), challengeFields(consumed)) + update(credential, quarantined, Credential.serializer(), credentialFields(quarantined)) + appendAudit(command.auditEvent) + CredentialQuarantineCommit(consumed, quarantined, command.auditEvent) + } + + override suspend fun mutateCredential(command: MutateCredentialCommand): StoreResult = atomic { + val existing = requireEntity(COLLECTION_CREDENTIALS, command.credentialId.value, Credential.serializer()) + requireVersion(existing.value.version, command.expectedVersion) + val replacement = command.replacement + if (replacement.webAuthnId != existing.value.webAuthnId || replacement.userId != existing.value.userId || + replacement.publicKey != existing.value.publicKey || replacement.signCount != existing.value.signCount || + replacement.backupEligible != existing.value.backupEligible || replacement.backedUp != existing.value.backedUp || + replacement.discoverable != existing.value.discoverable || replacement.createdAt != existing.value.createdAt || + replacement.lastUsedAt != existing.value.lastUsedAt || replacement.updatedAt < existing.value.updatedAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + when (command.auditEvent.action) { + AuditAction.CREDENTIAL_RENAMED -> if (replacement.state != existing.value.state || + replacement.name == existing.value.name || replacement.revokedAt != existing.value.revokedAt || + replacement.revocationReasonCode != existing.value.revocationReasonCode + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + AuditAction.CREDENTIAL_REVOKED -> if (existing.value.state == CredentialState.REVOKED || + replacement.state != CredentialState.REVOKED || replacement.revokedAt == null || + replacement.revocationReasonCode.isNullOrBlank() + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + else -> abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + requireAuditAvailable(command.auditEvent) + update(existing, replacement, Credential.serializer(), credentialFields(replacement)) + appendAudit(command.auditEvent) + replacement + } + + override suspend fun createSession(command: CreateSessionCommand): StoreResult = atomic { + val user = requireEntity(COLLECTION_USERS, command.session.userId.value, User.serializer()) + requireNewSession(command.session, user.value) + requireAuditAvailable(command.auditEvent) + createSessionValue(command.session) + appendAudit(command.auditEvent) + command.session + } + + override suspend fun touchIdentitySession(command: TouchIdentitySessionCommand): StoreResult = atomic { + val current = requireActiveSession( + command.sessionId, + command.expectedVersion, + command.lastUsedAt, + validateFederationProvider = true + ) + if (command.lastUsedAt < current.value.lastUsedAt || + command.idleExpiresAt < command.lastUsedAt || + command.idleExpiresAt > current.value.absoluteExpiresAt + ) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + val renewed = current.value.copy( + version = current.value.version + 1, + lastUsedAt = command.lastUsedAt, + idleExpiresAt = command.idleExpiresAt + ) + update(current, renewed, IdentitySession.serializer(), sessionFields(renewed)) + renewed + } + + override suspend fun rotateSession(command: RotateSessionCommand): StoreResult = atomic { + val previous = requireActiveSession( + command.sessionId, + command.expectedVersion, + command.rotatedAt, + validateFederationProvider = true + ) + val user = requireEntity(COLLECTION_USERS, previous.value.userId.value, User.serializer()) + requireNewSession(command.replacement, user.value) + if (command.replacement.userId != previous.value.userId || + command.replacement.familyId != previous.value.familyId || + command.replacement.rotationCounter != previous.value.rotationCounter + 1 || + command.replacement.createdAt != command.rotatedAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + val rotated = rotateSessionValue(previous.value, command.replacement.id) + update(previous, rotated, IdentitySession.serializer(), sessionFields(rotated)) + createSessionValue(command.replacement) + appendAudit(command.auditEvent) + SessionRotationCommit(rotated, command.replacement, command.auditEvent) + } + + override suspend fun revokeSession(command: RevokeSessionCommand): StoreResult = atomic { + val session = requireActiveSession(command.sessionId, command.expectedVersion, command.revokedAt) + requireAuditAvailable(command.auditEvent) + val revoked = revokeSessionValue(session.value, command.revokedAt, command.reasonCode) + update(session, revoked, IdentitySession.serializer(), sessionFields(revoked)) + appendAudit(command.auditEvent) + revoked + } + + override suspend fun revokeUserSessions(command: RevokeUserSessionsCommand): StoreResult = atomic { + val user = requireEntity(COLLECTION_USERS, command.userId.value, User.serializer()) + requireVersion(user.value.version, command.expectedUserVersion) + if (user.value.sessionEpoch != command.expectedSessionEpoch) abortVersion() + val sessions = query(COLLECTION_SESSIONS, IdentitySession.serializer(), FIELD_USER_ID, command.userId.value) + command.exceptSessionId?.let { exceptId -> + val except = sessions.firstOrNull { it.value.id == exceptId } + ?: abort(IdentityStoreErrorCode.SESSION_NOT_ACTIVE) + if (except.value.state != SessionState.ACTIVE) abort(IdentityStoreErrorCode.SESSION_NOT_ACTIVE) + } + requireAuditAvailable(command.auditEvent) + val revokedIds = mutableListOf() + sessions.filter { it.value.state == SessionState.ACTIVE }.forEach { current -> + val replacement = if (current.value.id == command.exceptSessionId) { + current.value.copy(userSessionEpoch = command.newSessionEpoch, version = current.value.version + 1) + } else { + revokedIds += current.value.id + revokeSessionValue(current.value, command.revokedAt, command.reasonCode) + } + update(current, replacement, IdentitySession.serializer(), sessionFields(replacement)) + } + val updatedUser = user.value.copy( + sessionEpoch = command.newSessionEpoch, + version = user.value.version + 1, + updatedAt = command.revokedAt + ) + update(user, updatedUser, User.serializer(), userFields(updatedUser)) + appendAudit(command.auditEvent) + RevokeUserSessionsCommit(updatedUser, revokedIds.sortedBy { it.value }, command.auditEvent) + } + + override suspend fun acquireFederationProviderLease( + command: AcquireFederationProviderLeaseCommand + ): StoreResult = atomic { + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + command.organizationId.value, + Organization.serializer() + ) + if (organization.value.state != OrganizationState.ACTIVE) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + val routeMatch = federationProviderByRoute(command.organizationId, command.providerId) + val storageMatch = federationProviderByStorageKey(command.storageKey) + val existing = routeMatch ?: storageMatch + if (existing != null) { + if (routeMatch == null || storageMatch == null) { + if (existing.value.matches(command)) abort(IdentityStoreErrorCode.INTERNAL) + abort(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + } + if (routeMatch.value != storageMatch.value || !existing.value.matches(command)) { + abort(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + } + if (existing.value.state != FederationProviderState.ENABLED) { + abort(IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED) + } + return@atomic existing.value.lease() + } + val created = FederationProviderControl( + organizationId = command.organizationId, + kind = command.kind, + providerId = command.providerId, + storageKey = command.storageKey, + createdAt = command.acquiredAt, + updatedAt = command.acquiredAt + ) + claimFederationProviderUniqueness(created) + createFederationProviderControl(created) + created.lease() + } + + override suspend fun validateFederationProviderLease( + lease: FederationProviderLease + ): StoreResult = atomic { + requireFederationProviderLease(lease) + lease + } + + override suspend fun compareAndSetFederationProviderState( + command: CompareAndSetFederationProviderStateCommand + ): StoreResult = atomic { + val replacement = command.replacement + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + replacement.organizationId.value, + Organization.serializer() + ) + if (organization.value.state != OrganizationState.ACTIVE) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + val routeMatch = federationProviderByRoute(replacement.organizationId, replacement.providerId) + val storageMatch = federationProviderByStorageKey(replacement.storageKey) + if (command.expectedVersion == null) { + if (routeMatch != null) { + if (!routeMatch.value.hasSameIdentity(replacement)) { + abort(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + } + abortVersion() + } + if (storageMatch != null) { + abort(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + } + if (replacement.state != FederationProviderState.DISABLED || + replacement.version != 0L || replacement.sessionEpoch != 1L || + replacement.createdAt != replacement.updatedAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + claimFederationProviderUniqueness(replacement) + createFederationProviderControl(replacement) + } else { + val expectedVersion = command.expectedVersion + ?: abort(IdentityStoreErrorCode.VERSION_CONFLICT) + val existing = routeMatch ?: abortVersion() + requireVersion(existing.value.version, expectedVersion) + if (!existing.value.hasSameIdentity(replacement)) { + abort(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + } + if (existing.value.createdAt != replacement.createdAt) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + if (storageMatch == null || storageMatch.value != existing.value) { + abort(IdentityStoreErrorCode.INTERNAL) + } + if (replacement.updatedAt < existing.value.updatedAt || + existing.value.state == replacement.state + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + when (replacement.state) { + FederationProviderState.DISABLED -> if ( + existing.value.state != FederationProviderState.ENABLED || + replacement.sessionEpoch != existing.value.sessionEpoch + 1 + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + FederationProviderState.ENABLED -> if ( + existing.value.state != FederationProviderState.DISABLED || + replacement.sessionEpoch != existing.value.sessionEpoch + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + requireAuditAvailable(command.auditEvent) + update( + existing, + replacement, + FederationProviderControl.serializer(), + federationProviderControlFields(replacement) + ) + } + appendAudit(command.auditEvent) + FederationProviderStateCommit(replacement, command.auditEvent) + } + + override suspend fun replaceRecoveryCodes( + command: ReplaceRecoveryCodesCommand + ): StoreResult = atomic { + requireEntity(COLLECTION_USERS, command.userId.value, User.serializer()) + val existing = query(COLLECTION_RECOVERY_CODES, RecoveryCode.serializer(), FIELD_USER_ID, command.userId.value) + val currentGeneration = existing.maxOfOrNull { it.value.generation } + if (currentGeneration != command.expectedGeneration) abortVersion() + if (command.codes.any { it.version != 0L }) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + command.codes.forEach { code -> + requireAbsent(COLLECTION_RECOVERY_CODES, code.id.value) + claimUnique(UNIQUE_RECOVERY_SELECTOR, code.publicSelector, COLLECTION_RECOVERY_CODES, code.id.value) + claimUnique( + UNIQUE_RECOVERY_DIGEST, + digestUniqueValue(code.secretDigest), + COLLECTION_RECOVERY_CODES, + code.id.value + ) + } + requireAuditAvailable(command.auditEvent) + existing.filter { it.value.state == RecoveryCodeState.ACTIVE }.forEach { current -> + val revoked = current.value.copy(state = RecoveryCodeState.REVOKED, version = current.value.version + 1) + update(current, revoked, RecoveryCode.serializer(), recoveryFields(revoked)) + } + command.codes.forEach { code -> + create(COLLECTION_RECOVERY_CODES, code.id.value, code, RecoveryCode.serializer(), recoveryFields(code)) + } + appendAudit(command.auditEvent) + RecoveryCodeReplacementCommit(command.newGeneration, command.codes, command.auditEvent) + } + + override suspend fun consumeRecoveryCode( + command: ConsumeRecoveryCodeCommand + ): StoreResult = atomic { + val code = requireEntity(COLLECTION_RECOVERY_CODES, command.recoveryCodeId.value, RecoveryCode.serializer()) + val codeExpiresAt = code.value.expiresAt + if (code.value.state != RecoveryCodeState.ACTIVE || + (codeExpiresAt != null && command.consumedAt >= codeExpiresAt) + ) abort(IdentityStoreErrorCode.RECOVERY_CODE_NOT_ACTIVE) + requireVersion(code.value.version, command.expectedVersion) + val user = requireEntity(COLLECTION_USERS, code.value.userId.value, User.serializer()) + requireNewSession(command.recoverySession, user.value) + if (command.recoverySession.userId != code.value.userId || command.recoverySession.createdAt != command.consumedAt) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + requireAuditAvailable(command.auditEvent) + val consumed = code.value.copy( + state = RecoveryCodeState.CONSUMED, + version = code.value.version + 1, + consumedAt = command.consumedAt + ) + update(code, consumed, RecoveryCode.serializer(), recoveryFields(consumed)) + createSessionValue(command.recoverySession) + appendAudit(command.auditEvent) + RecoveryCodeConsumptionCommit(consumed, command.recoverySession, command.auditEvent) + } + + override suspend fun activateAdministrativeRecoveryTicket( + command: ActivateAdministrativeRecoveryTicketCommand + ): StoreResult = atomic { + val challenge = requireChallenge( + command.challengeId, + command.expectedChallengeVersion, + command.activatedAt + ) + val userId = challenge.value.userId + val auditTarget = command.auditEvent.target + if (challenge.value.purpose != ChallengePurpose.ACCOUNT_RECOVERY || userId == null || + challenge.value.activatedAt != null || auditTarget?.type != AuditTargetType.USER || + auditTarget.id != userId.value + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + val activated = challenge.value.copy( + activatedAt = command.activatedAt, + version = challenge.value.version + 1 + ) + update(challenge, activated, Challenge.serializer(), challengeFields(activated)) + appendAudit(command.auditEvent) + AdministrativeRecoveryTicketActivationCommit(activated, command.auditEvent) + } + + override suspend fun redeemAdministrativeRecoveryTicket( + command: RedeemAdministrativeRecoveryTicketCommand + ): StoreResult = atomic { + val challenge = requireChallenge(command.challengeId, command.expectedChallengeVersion, command.redeemedAt) + val activatedAt = challenge.value.activatedAt + if (challenge.value.purpose != ChallengePurpose.ACCOUNT_RECOVERY || challenge.value.userId == null || + activatedAt == null || activatedAt > command.redeemedAt || + command.recoverySession.userId != challenge.value.userId + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val ticketUserId = requireNotNull(challenge.value.userId) + val user = requireEntity(COLLECTION_USERS, ticketUserId.value, User.serializer()) + requireNewSession(command.recoverySession, user.value) + if (command.recoverySession.familyId != command.recoverySession.id || + command.recoverySession.rotatedFromId != null || command.recoverySession.rotationCounter != 0L + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + val consumed = consumeChallengeValue(challenge.value, command.redeemedAt) + update(challenge, consumed, Challenge.serializer(), challengeFields(consumed)) + createSessionValue(command.recoverySession) + appendAudit(command.auditEvent) + AdministrativeRecoveryTicketRedemptionCommit(consumed, command.recoverySession, command.auditEvent) + } + + override suspend fun completeRecoveryEnrollment( + command: CompleteRecoveryEnrollmentCommand + ): StoreResult> = atomicWebAuthn( + command.challengeId, + command.expectedChallengeVersion, + command.completedAt, + command.rejectionAuditEvent + ) { + val challenge = requireChallenge(command.challengeId, command.expectedChallengeVersion, command.completedAt) + if (challenge.value.purpose != ChallengePurpose.WEBAUTHN_REGISTRATION || + challenge.value.userId != command.credential.userId + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val recoverySession = requireActiveSession( + command.recoverySessionId, + command.expectedRecoverySessionVersion, + command.completedAt + ) + if (recoverySession.value.userId != command.credential.userId || + recoverySession.value.assurance != AuthenticationAssurance.RECOVERY + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val user = requireEntity(COLLECTION_USERS, command.credential.userId.value, User.serializer()) + requireVersion(user.value.version, command.expectedUserVersion) + if (user.value.sessionEpoch != command.expectedSessionEpoch || + recoverySession.value.userSessionEpoch != user.value.sessionEpoch + ) abortVersion() + val existingCodes = query( + COLLECTION_RECOVERY_CODES, + RecoveryCode.serializer(), + FIELD_USER_ID, + user.value.id.value + ) + val currentGeneration = existingCodes.maxOfOrNull { it.value.generation } + if (currentGeneration != command.expectedRecoveryGeneration) abortVersion() + requireAbsent(COLLECTION_CREDENTIALS, command.credential.id.value) + claimUnique( + UNIQUE_WEBAUTHN_ID, + command.credential.webAuthnId.encoded, + COLLECTION_CREDENTIALS, + command.credential.id.value + ) + command.replacementRecoveryCodes.forEach { code -> + requireAbsent(COLLECTION_RECOVERY_CODES, code.id.value) + claimUnique(UNIQUE_RECOVERY_SELECTOR, code.publicSelector, COLLECTION_RECOVERY_CODES, code.id.value) + claimUnique( + UNIQUE_RECOVERY_DIGEST, + digestUniqueValue(code.secretDigest), + COLLECTION_RECOVERY_CODES, + code.id.value + ) + } + val userSessions = query(COLLECTION_SESSIONS, IdentitySession.serializer(), FIELD_USER_ID, user.value.id.value) + requireAuditAvailable(command.auditEvent) + + val consumedChallenge = consumeChallengeValue(challenge.value, command.completedAt) + update(challenge, consumedChallenge, Challenge.serializer(), challengeFields(consumedChallenge)) + create( + COLLECTION_CREDENTIALS, + command.credential.id.value, + command.credential, + Credential.serializer(), + credentialFields(command.credential) + ) + val updatedUser = user.value.copy( + sessionEpoch = command.newSessionEpoch, + version = user.value.version + 1, + updatedAt = command.completedAt + ) + update(user, updatedUser, User.serializer(), userFields(updatedUser)) + val revokedSessionIds = userSessions.filter { it.value.state == SessionState.ACTIVE }.map { session -> + val revoked = revokeSessionValue(session.value, command.completedAt, "recovery_enrollment_completed") + update(session, revoked, IdentitySession.serializer(), sessionFields(revoked)) + session.value.id + }.sortedBy { it.value } + existingCodes.filter { it.value.state == RecoveryCodeState.ACTIVE }.forEach { code -> + val revoked = code.value.copy(state = RecoveryCodeState.REVOKED, version = code.value.version + 1) + update(code, revoked, RecoveryCode.serializer(), recoveryFields(revoked)) + } + command.replacementRecoveryCodes.forEach { code -> + create(COLLECTION_RECOVERY_CODES, code.id.value, code, RecoveryCode.serializer(), recoveryFields(code)) + } + appendAudit(command.auditEvent) + RecoveryEnrollmentCommit( + consumedChallenge, + command.credential, + updatedUser, + revokedSessionIds, + command.newRecoveryGeneration, + command.replacementRecoveryCodes, + command.auditEvent + ) + } + + override suspend fun createOrganization( + command: CreateOrganizationCommand + ): StoreResult = atomic { + requireAbsent(COLLECTION_ORGANIZATIONS, command.organization.id.value) + requireAbsent(COLLECTION_MEMBERSHIPS, command.ownerMembership.id.value) + requireEntity(COLLECTION_USERS, command.ownerMembership.userId.value, User.serializer()) + claimUnique( + UNIQUE_ORGANIZATION_SLUG, + command.organization.slug, + COLLECTION_ORGANIZATIONS, + command.organization.id.value + ) + claimUnique( + UNIQUE_MEMBERSHIP, + membershipUniqueValue(command.ownerMembership.userId, command.organization.id), + COLLECTION_MEMBERSHIPS, + command.ownerMembership.id.value + ) + requireAuditAvailable(command.auditEvent) + create( + COLLECTION_ORGANIZATIONS, + command.organization.id.value, + command.organization, + Organization.serializer(), + organizationFields(command.organization) + ) + create( + COLLECTION_MEMBERSHIPS, + command.ownerMembership.id.value, + command.ownerMembership, + Membership.serializer(), + membershipFields(command.ownerMembership) + ) + appendAudit(command.auditEvent) + OrganizationCreationCommit(command.organization, command.ownerMembership, command.auditEvent) + } + + override suspend fun mutateOrganization(command: MutateOrganizationCommand): StoreResult = atomic { + val existing = requireEntity( + COLLECTION_ORGANIZATIONS, + command.organizationId.value, + Organization.serializer() + ) + requireVersion(existing.value.version, command.expectedVersion) + if (existing.value.state == OrganizationState.DELETED || existing.value.slug != command.replacement.slug) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + requireAuditAvailable(command.auditEvent) + update( + existing, + command.replacement, + Organization.serializer(), + organizationFields(command.replacement) + ) + appendAudit(command.auditEvent) + command.replacement + } + + override suspend fun createInvitation(command: CreateInvitationCommand): StoreResult = atomic { + requireAbsent(COLLECTION_INVITATIONS, command.invitation.id.value) + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + command.invitation.organizationId.value, + Organization.serializer() + ) + if (organization.value.state != OrganizationState.ACTIVE) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + command.invitation.invitedByUserId?.let { requireEntity(COLLECTION_USERS, it.value, User.serializer()) } + claimUnique( + UNIQUE_INVITATION_DIGEST, + digestUniqueValue(command.invitation.tokenDigest), + COLLECTION_INVITATIONS, + command.invitation.id.value + ) + claimUnique( + UNIQUE_PENDING_INVITATION, + pendingInvitationUniqueValue(command.invitation.organizationId, command.invitation.email), + COLLECTION_INVITATIONS, + command.invitation.id.value + ) + requireAuditAvailable(command.auditEvent) + create( + COLLECTION_INVITATIONS, + command.invitation.id.value, + command.invitation, + Invitation.serializer(), + invitationFields(command.invitation) + ) + appendAudit(command.auditEvent) + command.invitation + } + + override suspend fun mutateInvitation(command: MutateInvitationCommand): StoreResult = atomic { + val existing = requireEntity(COLLECTION_INVITATIONS, command.invitationId.value, Invitation.serializer()) + requireVersion(existing.value.version, command.expectedVersion) + if (existing.value.state != InvitationState.PENDING || + existing.value.organizationId != command.replacement.organizationId || + existing.value.email != command.replacement.email || existing.value.role != command.replacement.role || + existing.value.tokenDigest != command.replacement.tokenDigest || + existing.value.createdAt != command.replacement.createdAt || + existing.value.expiresAt != command.replacement.expiresAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + releaseUnique( + UNIQUE_PENDING_INVITATION, + pendingInvitationUniqueValue(existing.value.organizationId, existing.value.email), + COLLECTION_INVITATIONS, + existing.value.id.value + ) + update(existing, command.replacement, Invitation.serializer(), invitationFields(command.replacement)) + appendAudit(command.auditEvent) + command.replacement + } + + override suspend fun enrollInvitation( + command: EnrollInvitationCommand + ): StoreResult = atomic { + val invitation = requireEntity( + COLLECTION_INVITATIONS, + command.invitationId.value, + Invitation.serializer() + ) + requireVersion(invitation.value.version, command.expectedInvitationVersion) + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + invitation.value.organizationId.value, + Organization.serializer() + ) + if (invitation.value.tokenDigest != command.expectedTokenDigest || + invitation.value.state != InvitationState.PENDING || + invitation.value.expiresAt <= command.enrolledAt || + organization.value.state != OrganizationState.ACTIVE || + normalizeEmail(invitation.value.email) != normalizeEmail(requireNotNull(command.user.primaryEmail)) || + command.membership.organizationId != invitation.value.organizationId || + command.membership.role != invitation.value.role || + command.auditEvent.organizationId != invitation.value.organizationId + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + + requireAbsent(COLLECTION_USERS, command.user.id.value) + claimUnique( + UNIQUE_EMAIL, + normalizeEmail(requireNotNull(command.user.primaryEmail)), + COLLECTION_USERS, + command.user.id.value + ) + requireAbsent(COLLECTION_MEMBERSHIPS, command.membership.id.value) + claimUnique( + UNIQUE_MEMBERSHIP, + membershipUniqueValue(command.user.id, invitation.value.organizationId), + COLLECTION_MEMBERSHIPS, + command.membership.id.value + ) + requireNewSession(command.enrollmentSession, command.user) + requireAuditAvailable(command.auditEvent) + + val accepted = invitation.value.copy( + state = InvitationState.ACCEPTED, + version = invitation.value.version + 1, + acceptedAt = command.enrolledAt, + acceptedByUserId = command.user.id + ) + releaseUnique( + UNIQUE_PENDING_INVITATION, + pendingInvitationUniqueValue(invitation.value.organizationId, invitation.value.email), + COLLECTION_INVITATIONS, + invitation.value.id.value + ) + create( + COLLECTION_USERS, + command.user.id.value, + command.user, + User.serializer(), + userFields(command.user) + ) + create( + COLLECTION_MEMBERSHIPS, + command.membership.id.value, + command.membership, + Membership.serializer(), + membershipFields(command.membership) + ) + createSessionValue(command.enrollmentSession) + update(invitation, accepted, Invitation.serializer(), invitationFields(accepted)) + appendAudit(command.auditEvent) + InvitationEnrollmentCommit( + invitation = accepted, + user = command.user, + membership = command.membership, + enrollmentSession = command.enrollmentSession, + auditEvent = command.auditEvent + ) + } + + override suspend fun createMembership(command: CreateMembershipCommand): StoreResult = atomic { + val membership = command.membership + requireAbsent(COLLECTION_MEMBERSHIPS, membership.id.value) + val user = requireEntity(COLLECTION_USERS, membership.userId.value, User.serializer()) + requireEntity(COLLECTION_ORGANIZATIONS, membership.organizationId.value, Organization.serializer()) + claimUnique( + UNIQUE_MEMBERSHIP, + membershipUniqueValue(membership.userId, membership.organizationId), + COLLECTION_MEMBERSHIPS, + membership.id.value + ) + requireAuditAvailable(command.auditEvent) + val invitation = command.invitationId?.let { id -> + val current = requireEntity(COLLECTION_INVITATIONS, id.value, Invitation.serializer()) + requireVersion(current.value.version, command.expectedInvitationVersion!!) + if (current.value.state != InvitationState.PENDING || + current.value.expiresAt <= command.auditEvent.occurredAt || + current.value.organizationId != membership.organizationId + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + user.value.primaryEmail?.let { + if (normalizeEmail(it) != normalizeEmail(current.value.email)) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + current to current.value.copy( + state = InvitationState.ACCEPTED, + version = current.value.version + 1, + acceptedAt = command.auditEvent.occurredAt, + acceptedByUserId = membership.userId + ) + } + create(COLLECTION_MEMBERSHIPS, membership.id.value, membership, Membership.serializer(), membershipFields(membership)) + invitation?.let { (current, replacement) -> + releaseUnique( + UNIQUE_PENDING_INVITATION, + pendingInvitationUniqueValue(current.value.organizationId, current.value.email), + COLLECTION_INVITATIONS, + current.value.id.value + ) + update(current, replacement, Invitation.serializer(), invitationFields(replacement)) + } + appendAudit(command.auditEvent) + membership + } + + override suspend fun mutateMembership(command: MutateMembershipCommand): StoreResult = atomic { + val existing = requireEntity(COLLECTION_MEMBERSHIPS, command.membershipId.value, Membership.serializer()) + requireVersion(existing.value.version, command.expectedVersion) + if (existing.value.organizationId != command.replacement.organizationId || + existing.value.userId != command.replacement.userId + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val removesOwner = existing.value.state == MembershipState.ACTIVE && + existing.value.role == OrganizationRole.OWNER && + (command.replacement.state != MembershipState.ACTIVE || command.replacement.role != OrganizationRole.OWNER) + if (removesOwner) { + val memberships = query( + COLLECTION_MEMBERSHIPS, + Membership.serializer(), + FIELD_ORGANIZATION_ID, + existing.value.organizationId.value + ) + val anotherOwner = memberships.any { + it.value.id != existing.value.id && it.value.state == MembershipState.ACTIVE && + it.value.role == OrganizationRole.OWNER + } + if (!anotherOwner) abort(IdentityStoreErrorCode.LAST_OWNER) + } + requireAuditAvailable(command.auditEvent) + command.expectedUserVersion?.let { expectedUserVersion -> + val user = requireEntity(COLLECTION_USERS, existing.value.userId.value, User.serializer()) + requireVersion(user.value.version, expectedUserVersion) + if (user.value.sessionEpoch != command.expectedSessionEpoch) abortVersion() + val replacementUser = user.value.copy( + sessionEpoch = requireNotNull(command.newSessionEpoch), + version = user.value.version + 1, + updatedAt = requireNotNull(command.sessionsRevokedAt) + ) + update(user, replacementUser, User.serializer(), userFields(replacementUser)) + } + update(existing, command.replacement, Membership.serializer(), membershipFields(command.replacement)) + appendAudit(command.auditEvent) + command.replacement + } + + override suspend fun createServiceIdentity( + command: CreateServiceIdentityCommand + ): StoreResult = atomic { + requireAbsent(COLLECTION_SERVICE_IDENTITIES, command.identity.id.value) + requireAbsent(COLLECTION_SERVICE_CREDENTIALS, command.initialCredential.id.value) + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + command.identity.organizationId.value, + Organization.serializer() + ) + if (organization.value.state != OrganizationState.ACTIVE || + command.initialCredential.expiresAt?.let { it > command.identity.createdAt } != true + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + claimUnique( + UNIQUE_SERVICE_PREFIX, + command.initialCredential.publicPrefix, + COLLECTION_SERVICE_CREDENTIALS, + command.initialCredential.id.value + ) + claimUnique( + UNIQUE_SERVICE_DIGEST, + digestUniqueValue(command.initialCredential.secretDigest), + COLLECTION_SERVICE_CREDENTIALS, + command.initialCredential.id.value + ) + requireAuditAvailable(command.auditEvent) + create( + COLLECTION_SERVICE_IDENTITIES, + command.identity.id.value, + command.identity, + ServiceIdentity.serializer(), + serviceIdentityFields(command.identity) + ) + create( + COLLECTION_SERVICE_CREDENTIALS, + command.initialCredential.id.value, + command.initialCredential, + ServiceCredential.serializer(), + serviceCredentialFields(command.initialCredential) + ) + appendAudit(command.auditEvent) + ServiceIdentityCreationCommit(command.identity, command.initialCredential, command.auditEvent) + } + + override suspend fun mutateServiceIdentity( + command: MutateServiceIdentityCommand + ): StoreResult = atomic { + val existing = requireEntity( + COLLECTION_SERVICE_IDENTITIES, + command.serviceIdentityId.value, + ServiceIdentity.serializer() + ) + requireVersion(existing.value.version, command.expectedVersion) + if (existing.value.state == ServiceIdentityState.REVOKED || + existing.value.organizationId != command.replacement.organizationId || + existing.value.createdAt != command.replacement.createdAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + update( + existing, + command.replacement, + ServiceIdentity.serializer(), + serviceIdentityFields(command.replacement) + ) + if (command.replacement.state == ServiceIdentityState.REVOKED) { + query( + COLLECTION_SERVICE_CREDENTIALS, + ServiceCredential.serializer(), + FIELD_SERVICE_IDENTITY_ID, + existing.value.id.value + ).filter { + it.value.state == ServiceCredentialState.ACTIVE || it.value.state == ServiceCredentialState.ROTATED + }.forEach { credential -> + val revoked = credential.value.copy( + state = ServiceCredentialState.REVOKED, + version = credential.value.version + 1, + revokedAt = command.changedAt + ) + update(credential, revoked, ServiceCredential.serializer(), serviceCredentialFields(revoked)) + } + } + appendAudit(command.auditEvent) + command.replacement + } + + override suspend fun createServiceCredential( + command: CreateServiceCredentialCommand + ): StoreResult = atomic { + val identity = requireEntity( + COLLECTION_SERVICE_IDENTITIES, + command.credential.serviceIdentityId.value, + ServiceIdentity.serializer() + ) + if (identity.value.state != ServiceIdentityState.ACTIVE || + !identity.value.capabilities.containsAll(command.credential.capabilities) + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAbsent(COLLECTION_SERVICE_CREDENTIALS, command.credential.id.value) + claimUnique( + UNIQUE_SERVICE_PREFIX, + command.credential.publicPrefix, + COLLECTION_SERVICE_CREDENTIALS, + command.credential.id.value + ) + claimUnique( + UNIQUE_SERVICE_DIGEST, + digestUniqueValue(command.credential.secretDigest), + COLLECTION_SERVICE_CREDENTIALS, + command.credential.id.value + ) + requireAuditAvailable(command.auditEvent) + create( + COLLECTION_SERVICE_CREDENTIALS, + command.credential.id.value, + command.credential, + ServiceCredential.serializer(), + serviceCredentialFields(command.credential) + ) + appendAudit(command.auditEvent) + command.credential + } + + override suspend fun revokeServiceCredential( + command: RevokeServiceCredentialCommand + ): StoreResult = atomic { + val existing = requireEntity( + COLLECTION_SERVICE_CREDENTIALS, + command.credentialId.value, + ServiceCredential.serializer() + ) + requireVersion(existing.value.version, command.expectedVersion) + if (existing.value.state != ServiceCredentialState.ACTIVE && + existing.value.state != ServiceCredentialState.ROTATED + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + val revoked = existing.value.copy( + state = ServiceCredentialState.REVOKED, + version = existing.value.version + 1, + revokedAt = command.revokedAt + ) + update(existing, revoked, ServiceCredential.serializer(), serviceCredentialFields(revoked)) + appendAudit(command.auditEvent) + revoked + } + + override suspend fun compareAndSetDeviceGrant( + command: CompareAndSetDeviceGrantCommand + ): StoreResult = atomic { + val replacement = command.replacement + val existing = get(COLLECTION_DEVICE_GRANTS, replacement.id.value, DeviceGrant.serializer()) + val expectedVersion = command.expectedVersion + if (expectedVersion == null) { + if (existing != null) abortVersion() + claimUnique(UNIQUE_DEVICE_CODE, digestUniqueValue(replacement.deviceCodeDigest), COLLECTION_DEVICE_GRANTS, replacement.id.value) + claimUnique(UNIQUE_USER_CODE, digestUniqueValue(replacement.userCodeDigest), COLLECTION_DEVICE_GRANTS, replacement.id.value) + create(COLLECTION_DEVICE_GRANTS, replacement.id.value, replacement, DeviceGrant.serializer(), deviceGrantFields(replacement)) + } else { + existing ?: abort(IdentityStoreErrorCode.NOT_FOUND) + requireVersion(existing.value.version, expectedVersion) + requireDeviceGrantTransition(existing.value, replacement) + update(existing, replacement, DeviceGrant.serializer(), deviceGrantFields(replacement)) + } + requireAuditAvailable(command.auditEvent) + appendAudit(command.auditEvent) + replacement + } + + override suspend fun exchangeDeviceGrant( + command: ExchangeDeviceGrantCommand + ): StoreResult = atomic { + val grant = requireEntity(COLLECTION_DEVICE_GRANTS, command.deviceGrantId.value, DeviceGrant.serializer()) + requireVersion(grant.value.version, command.expectedDeviceGrantVersion) + val membership = requireEntity( + COLLECTION_MEMBERSHIPS, + command.family.membershipId.value, + Membership.serializer() + ) + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + command.family.organizationId.value, + Organization.serializer() + ) + val user = requireEntity(COLLECTION_USERS, command.family.userId.value, User.serializer()) + if (grant.value.state != DeviceGrantState.AUTHORIZED || command.exchangedAt >= grant.value.expiresAt || + command.family.clientId != grant.value.clientId || + command.family.userId != grant.value.userId || + command.family.organizationId != grant.value.organizationId || + command.family.membershipId != grant.value.membershipId || + command.family.membershipVersion != grant.value.membershipVersion || + membership.value.userId != command.family.userId || + membership.value.organizationId != command.family.organizationId || + membership.value.state != MembershipState.ACTIVE || + membership.value.version != command.family.membershipVersion || + organization.value.state != OrganizationState.ACTIVE || + user.value.state != UserState.ACTIVE || + command.auditEvent.organizationId != command.family.organizationId || + command.auditEvent.target != AuditTarget(AuditTargetType.DEVICE_GRANT, grant.value.id.value) || + command.family.capabilities != grant.value.approvedCapabilities || + command.family.createdAt != command.exchangedAt || + command.accessToken.createdAt != command.exchangedAt || + command.refreshToken.createdAt != command.exchangedAt || + command.accessToken.expiresAt > command.family.expiresAt || + command.refreshToken.expiresAt > command.family.expiresAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAbsent(COLLECTION_DEVICE_TOKEN_FAMILIES, command.family.id.value) + requireAbsent(COLLECTION_DEVICE_ACCESS_TOKENS, command.accessToken.id.value) + requireAbsent(COLLECTION_DEVICE_REFRESH_TOKENS, command.refreshToken.id.value) + claimDeviceToken(UNIQUE_DEVICE_ACCESS_SELECTOR, command.accessToken.publicSelector, command.accessToken.secretDigest, + COLLECTION_DEVICE_ACCESS_TOKENS, command.accessToken.id.value) + claimDeviceToken(UNIQUE_DEVICE_REFRESH_SELECTOR, command.refreshToken.publicSelector, command.refreshToken.secretDigest, + COLLECTION_DEVICE_REFRESH_TOKENS, command.refreshToken.id.value) + requireAuditAvailable(command.auditEvent) + val consumed = grant.value.copy( + state = DeviceGrantState.CONSUMED, + version = grant.value.version + 1, + consumedAt = command.exchangedAt + ) + update(grant, consumed, DeviceGrant.serializer(), deviceGrantFields(consumed)) + create(COLLECTION_DEVICE_TOKEN_FAMILIES, command.family.id.value, command.family, + DeviceTokenFamily.serializer(), deviceTokenFamilyFields(command.family)) + create(COLLECTION_DEVICE_ACCESS_TOKENS, command.accessToken.id.value, command.accessToken, + DeviceAccessToken.serializer(), deviceAccessTokenFields(command.accessToken)) + create(COLLECTION_DEVICE_REFRESH_TOKENS, command.refreshToken.id.value, command.refreshToken, + DeviceRefreshToken.serializer(), deviceRefreshTokenFields(command.refreshToken)) + appendAudit(command.auditEvent) + DeviceTokenIssuanceCommit( + consumed, command.family, command.accessToken, command.refreshToken, command.auditEvent + ) + } + + override suspend fun rotateDeviceRefreshToken( + command: RotateDeviceRefreshTokenCommand + ): StoreResult = atomic { + val previous = requireEntity( + COLLECTION_DEVICE_REFRESH_TOKENS, + command.refreshTokenId.value, + DeviceRefreshToken.serializer() + ) + requireVersion(previous.value.version, command.expectedRefreshTokenVersion) + val family = requireEntity( + COLLECTION_DEVICE_TOKEN_FAMILIES, + previous.value.familyId.value, + DeviceTokenFamily.serializer() + ) + requireVersion(family.value.version, command.expectedFamilyVersion) + val membership = requireEntity( + COLLECTION_MEMBERSHIPS, + family.value.membershipId.value, + Membership.serializer() + ) + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + family.value.organizationId.value, + Organization.serializer() + ) + val user = requireEntity(COLLECTION_USERS, family.value.userId.value, User.serializer()) + if (previous.value.state != DeviceRefreshTokenState.ACTIVE || command.rotatedAt >= previous.value.expiresAt || + family.value.state != DeviceTokenFamilyState.ACTIVE || command.rotatedAt >= family.value.expiresAt || + membership.value.userId != family.value.userId || + membership.value.organizationId != family.value.organizationId || + membership.value.state != MembershipState.ACTIVE || + membership.value.version != family.value.membershipVersion || + organization.value.state != OrganizationState.ACTIVE || + user.value.state != UserState.ACTIVE || + command.auditEvent.organizationId != family.value.organizationId || + command.auditEvent.target != AuditTarget( + AuditTargetType.DEVICE_GRANT, + family.value.deviceGrantId.value + ) || + command.replacementAccessToken.familyId != family.value.id || + command.replacementRefreshToken.familyId != family.value.id || + command.replacementRefreshToken.rotationCounter != previous.value.rotationCounter + 1 || + command.replacementAccessToken.createdAt != command.rotatedAt || + command.replacementRefreshToken.createdAt != command.rotatedAt || + command.replacementAccessToken.expiresAt > family.value.expiresAt || + command.replacementRefreshToken.expiresAt > family.value.expiresAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAbsent(COLLECTION_DEVICE_ACCESS_TOKENS, command.replacementAccessToken.id.value) + requireAbsent(COLLECTION_DEVICE_REFRESH_TOKENS, command.replacementRefreshToken.id.value) + claimDeviceToken(UNIQUE_DEVICE_ACCESS_SELECTOR, command.replacementAccessToken.publicSelector, + command.replacementAccessToken.secretDigest, + COLLECTION_DEVICE_ACCESS_TOKENS, command.replacementAccessToken.id.value) + claimDeviceToken(UNIQUE_DEVICE_REFRESH_SELECTOR, command.replacementRefreshToken.publicSelector, + command.replacementRefreshToken.secretDigest, + COLLECTION_DEVICE_REFRESH_TOKENS, command.replacementRefreshToken.id.value) + requireAuditAvailable(command.auditEvent) + val rotated = previous.value.copy( + state = DeviceRefreshTokenState.ROTATED, + version = previous.value.version + 1, + rotatedToId = command.replacementRefreshToken.id, + consumedAt = command.rotatedAt + ) + update(previous, rotated, DeviceRefreshToken.serializer(), deviceRefreshTokenFields(rotated)) + create(COLLECTION_DEVICE_ACCESS_TOKENS, command.replacementAccessToken.id.value, + command.replacementAccessToken, DeviceAccessToken.serializer(), + deviceAccessTokenFields(command.replacementAccessToken)) + create(COLLECTION_DEVICE_REFRESH_TOKENS, command.replacementRefreshToken.id.value, + command.replacementRefreshToken, DeviceRefreshToken.serializer(), + deviceRefreshTokenFields(command.replacementRefreshToken)) + appendAudit(command.auditEvent) + DeviceTokenRotationCommit( + family.value, + rotated, + command.replacementAccessToken, + command.replacementRefreshToken, + command.auditEvent + ) + } + + override suspend fun revokeDeviceTokenFamily( + command: RevokeDeviceTokenFamilyCommand + ): StoreResult = atomic { + val family = requireEntity( + COLLECTION_DEVICE_TOKEN_FAMILIES, + command.familyId.value, + DeviceTokenFamily.serializer() + ) + requireVersion(family.value.version, command.expectedFamilyVersion) + if (family.value.state != DeviceTokenFamilyState.ACTIVE) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val access = query( + COLLECTION_DEVICE_ACCESS_TOKENS, + DeviceAccessToken.serializer(), + FIELD_FAMILY_ID, + family.value.id.value + ) + val refresh = query( + COLLECTION_DEVICE_REFRESH_TOKENS, + DeviceRefreshToken.serializer(), + FIELD_FAMILY_ID, + family.value.id.value + ) + requireAuditAvailable(command.auditEvent) + val revokedAccessTokenIds = mutableListOf() + access.filter { it.value.state == DeviceAccessTokenState.ACTIVE }.forEach { token -> + val replacement = token.value.copy( + state = DeviceAccessTokenState.REVOKED, + version = token.value.version + 1, + revokedAt = command.revokedAt + ) + update( + token, + replacement, + DeviceAccessToken.serializer(), + deviceAccessTokenFields(replacement) + ) + revokedAccessTokenIds += token.value.id + } + val revokedRefreshTokenIds = mutableListOf() + refresh.filter { it.value.state == DeviceRefreshTokenState.ACTIVE }.forEach { token -> + val replacement = token.value.copy( + state = DeviceRefreshTokenState.REVOKED, + version = token.value.version + 1, + revokedAt = command.revokedAt + ) + update( + token, + replacement, + DeviceRefreshToken.serializer(), + deviceRefreshTokenFields(replacement) + ) + revokedRefreshTokenIds += token.value.id + } + val revoked = family.value.copy( + state = DeviceTokenFamilyState.REVOKED, + version = family.value.version + 1, + revokedAt = command.revokedAt, + revocationReasonCode = command.reasonCode + ) + update(family, revoked, DeviceTokenFamily.serializer(), deviceTokenFamilyFields(revoked)) + appendAudit(command.auditEvent) + DeviceTokenFamilyRevocationCommit( + revoked, + revokedAccessTokenIds.sortedBy { it.value }, + revokedRefreshTokenIds.sortedBy { it.value }, + command.auditEvent + ) + } + + override suspend fun rotateServiceCredential( + command: RotateServiceCredentialCommand + ): StoreResult = atomic { + val existing = requireEntity(COLLECTION_SERVICE_CREDENTIALS, command.credentialId.value, ServiceCredential.serializer()) + requireVersion(existing.value.version, command.expectedVersion) + val existingExpiresAt = existing.value.expiresAt + if (existing.value.state != ServiceCredentialState.ACTIVE || + (existingExpiresAt != null && command.rotatedAt >= existingExpiresAt) + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val identity = requireEntity( + COLLECTION_SERVICE_IDENTITIES, + existing.value.serviceIdentityId.value, + ServiceIdentity.serializer() + ) + if (command.replacement.serviceIdentityId != existing.value.serviceIdentityId || + !identity.value.capabilities.containsAll(command.replacement.capabilities) + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAbsent(COLLECTION_SERVICE_CREDENTIALS, command.replacement.id.value) + claimUnique( + UNIQUE_SERVICE_PREFIX, + command.replacement.publicPrefix, + COLLECTION_SERVICE_CREDENTIALS, + command.replacement.id.value + ) + claimUnique( + UNIQUE_SERVICE_DIGEST, + digestUniqueValue(command.replacement.secretDigest), + COLLECTION_SERVICE_CREDENTIALS, + command.replacement.id.value + ) + requireAuditAvailable(command.auditEvent) + val rotated = existing.value.copy( + state = ServiceCredentialState.ROTATED, + version = existing.value.version + 1, + rotatedToId = command.replacement.id, + rotatedAt = command.rotatedAt + ) + update(existing, rotated, ServiceCredential.serializer(), serviceCredentialFields(rotated)) + create( + COLLECTION_SERVICE_CREDENTIALS, + command.replacement.id.value, + command.replacement, + ServiceCredential.serializer(), + serviceCredentialFields(command.replacement) + ) + appendAudit(command.auditEvent) + ServiceCredentialRotationCommit(rotated, command.replacement, command.auditEvent) + } + + override suspend fun linkExternalIdentity( + command: LinkExternalIdentityCommand + ): StoreResult = atomic { + requireFederationProviderLease(command.federationProviderLease) + val occurredAt = command.auditEvent.occurredAt + val provisioning = command.jitProvisioning + if (provisioning == null) { + requireEntity(COLLECTION_USERS, command.identity.userId.value, User.serializer()) + } else { + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + provisioning.membership.organizationId.value, + Organization.serializer() + ) + if (organization.value.state != OrganizationState.ACTIVE || + provisioning.user.state != UserState.ACTIVE || + provisioning.user.version != 0L || provisioning.user.primaryEmail != null || + provisioning.membership.userId != provisioning.user.id || + provisioning.membership.organizationId != command.federationProviderLease.organizationId || + provisioning.membership.role != OrganizationRole.VIEWER || + provisioning.membership.state != MembershipState.ACTIVE || + provisioning.membership.version != 0L || + command.identity.userId != provisioning.user.id || + command.identity.createdAt != occurredAt || + command.identity.updatedAt != occurredAt || + provisioning.user.createdAt != occurredAt || + provisioning.user.updatedAt != occurredAt || + provisioning.user.activatedAt != occurredAt || + provisioning.membership.createdAt != occurredAt || + provisioning.membership.updatedAt != occurredAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAbsent(COLLECTION_USERS, provisioning.user.id.value) + requireAbsent(COLLECTION_MEMBERSHIPS, provisioning.membership.id.value) + claimUnique( + UNIQUE_MEMBERSHIP, + membershipUniqueValue(provisioning.user.id, provisioning.membership.organizationId), + COLLECTION_MEMBERSHIPS, + provisioning.membership.id.value + ) + } + requireAbsent(COLLECTION_EXTERNAL_IDENTITIES, command.identity.id.value) + claimUnique( + UNIQUE_EXTERNAL_IDENTITY, + externalUniqueValue(command.identity.provider, command.identity.subject), + COLLECTION_EXTERNAL_IDENTITIES, + command.identity.id.value + ) + requireReplayAvailable(command.replayReceipt) + if (command.replayReceipt.expiresAt <= occurredAt) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + requireAuditAvailable(command.auditEvent) + provisioning?.let { + create(COLLECTION_USERS, it.user.id.value, it.user, User.serializer(), userFields(it.user)) + create( + COLLECTION_MEMBERSHIPS, + it.membership.id.value, + it.membership, + Membership.serializer(), + membershipFields(it.membership) + ) + } + create( + COLLECTION_EXTERNAL_IDENTITIES, + command.identity.id.value, + command.identity, + ExternalIdentity.serializer(), + externalIdentityFields(command.identity) + ) + createReplayReceipt(command.replayReceipt) + appendAudit(command.auditEvent) + ExternalIdentityLinkCommit( + identity = command.identity, + replayReceipt = command.replayReceipt, + auditEvent = command.auditEvent, + provisionedUser = provisioning?.user, + provisionedMembership = provisioning?.membership + ) + } + + override suspend fun recordExternalIdentityReplay( + command: RecordExternalIdentityReplayCommand + ): StoreResult = atomic { + requireFederationProviderLease(command.federationProviderLease) + requireReplayAvailable(command.replayReceipt) + createReplayReceipt(command.replayReceipt) + command.replayReceipt + } + + override suspend fun applyScimMutation(command: ApplyScimMutationCommand): StoreResult = + atomic { applyScimMutationValue(command) } + + override suspend fun applyScimBatch(command: ApplyScimBatchCommand): StoreResult = atomic { + val receipt = get( + COLLECTION_SCIM_BATCH_RECEIPTS, + command.operationId.value, + AppliedScimBatch.serializer() + ) + if (receipt != null) { + if (receipt.value.command != command) abort(IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT) + return@atomic receipt.value.commit.copy( + mutationCommits = receipt.value.commit.mutationCommits.map { + it.copy(alreadyApplied = true, auditEvent = null) + }, + alreadyApplied = true, + auditEvent = null + ) + } + val organization = requireEntity( + COLLECTION_ORGANIZATIONS, + command.organizationId.value, + Organization.serializer() + ) + if (organization.value.state != OrganizationState.ACTIVE) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireAuditAvailable(command.auditEvent) + validateScimBatchLastOwner(command) + val pendingUserIds = command.mutations.mapNotNull { it.mutation.user?.id }.toSet() + val mutationCommits = command.mutations.map { + applyScimMutationValue(it, enforceLastOwner = false, pendingUserIds = pendingUserIds) + } + + val group = command.group?.also { aggregate -> + val expectedVersion = requireNotNull(command.expectedGroupVersion) + val existing = get(COLLECTION_SCIM_GROUPS, aggregate.id, ScimGroup.serializer()) + if (expectedVersion == 0L) { + if (existing != null) abort(IdentityStoreErrorCode.ALREADY_EXISTS) + create( + COLLECTION_SCIM_GROUPS, + aggregate.id, + aggregate, + ScimGroup.serializer(), + scimGroupFields(aggregate) + ) + } else { + if (existing == null || existing.value.provider != command.provider || + existing.value.organizationId != command.organizationId + ) abort(IdentityStoreErrorCode.NOT_FOUND) + requireVersion(existing.value.version, expectedVersion) + if (existing.value.createdAt != aggregate.createdAt) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + update(existing, aggregate, ScimGroup.serializer(), scimGroupFields(aggregate)) + } + aggregate.memberUserIds.forEach { userId -> + if (userId !in pendingUserIds) requireEntity(COLLECTION_USERS, userId.value, User.serializer()) + } + } + + val revokedSessionIds = mutableListOf() + val revokedFamilyIds = mutableListOf() + val revokedAccessIds = mutableListOf() + val revokedRefreshIds = mutableListOf() + val tenantSessions = if (command.revocations.any { it.revokeSessions }) { + query(COLLECTION_SESSIONS, IdentitySession.serializer(), FIELD_ORGANIZATION_ID, command.organizationId.value) + } else emptyList() + val tenantFamilies = if (command.revocations.any { it.revokeDeviceTokenFamilies }) { + query( + COLLECTION_DEVICE_TOKEN_FAMILIES, + DeviceTokenFamily.serializer(), + FIELD_ORGANIZATION_ID, + command.organizationId.value + ) + } else emptyList() + command.revocations.forEach { revocation -> + if (revocation.userId !in pendingUserIds) { + requireEntity(COLLECTION_USERS, revocation.userId.value, User.serializer()) + } + if (revocation.revokeSessions) { + tenantSessions.filter { session -> + session.value.userId == revocation.userId && session.value.state == SessionState.ACTIVE && + session.value.federationOrganizationId == command.organizationId + }.forEach { session -> + val revoked = revokeSessionValue( + session.value, + command.auditEvent.occurredAt, + revocation.reasonCode + ) + update( + session, + revoked, + IdentitySession.serializer(), + sessionFields(revoked) + ) + revokedSessionIds += session.value.id + } + } + if (revocation.revokeDeviceTokenFamilies) { + tenantFamilies.filter { family -> + family.value.userId == revocation.userId && family.value.state == DeviceTokenFamilyState.ACTIVE + }.forEach { family -> + val revokedFamily = family.value.copy( + state = DeviceTokenFamilyState.REVOKED, + version = family.value.version + 1, + revokedAt = command.auditEvent.occurredAt, + revocationReasonCode = revocation.reasonCode + ) + update( + family, + revokedFamily, + DeviceTokenFamily.serializer(), + deviceTokenFamilyFields(revokedFamily) + ) + revokedFamilyIds += family.value.id + query( + COLLECTION_DEVICE_ACCESS_TOKENS, + DeviceAccessToken.serializer(), + FIELD_FAMILY_ID, + family.value.id.value + ).filter { it.value.state == DeviceAccessTokenState.ACTIVE }.forEach { token -> + val revoked = token.value.copy( + state = DeviceAccessTokenState.REVOKED, + version = token.value.version + 1, + revokedAt = command.auditEvent.occurredAt + ) + update(token, revoked, DeviceAccessToken.serializer(), deviceAccessTokenFields(revoked)) + revokedAccessIds += token.value.id + } + query( + COLLECTION_DEVICE_REFRESH_TOKENS, + DeviceRefreshToken.serializer(), + FIELD_FAMILY_ID, + family.value.id.value + ).filter { it.value.state == DeviceRefreshTokenState.ACTIVE }.forEach { token -> + val revoked = token.value.copy( + state = DeviceRefreshTokenState.REVOKED, + version = token.value.version + 1, + revokedAt = command.auditEvent.occurredAt + ) + update(token, revoked, DeviceRefreshToken.serializer(), deviceRefreshTokenFields(revoked)) + revokedRefreshIds += token.value.id + } + } + } + } + appendAudit(command.auditEvent) + val commit = ScimBatchCommit( + mutationCommits = mutationCommits, + group = group, + revokedSessionIds = revokedSessionIds.distinct().sortedBy { it.value }, + revokedDeviceTokenFamilyIds = revokedFamilyIds.distinct().sortedBy { it.value }, + revokedDeviceAccessTokenIds = revokedAccessIds.distinct().sortedBy { it.value }, + revokedDeviceRefreshTokenIds = revokedRefreshIds.distinct().sortedBy { it.value }, + alreadyApplied = false, + auditEvent = command.auditEvent + ) + create( + COLLECTION_SCIM_BATCH_RECEIPTS, + command.operationId.value, + AppliedScimBatch(command, commit), + AppliedScimBatch.serializer(), + mapOf(FIELD_PROVIDER to command.provider, FIELD_ORGANIZATION_ID to command.organizationId.value) + ) + commit + } + + private suspend fun readEntity(collection: String, id: String, serializer: KSerializer): StoreResult { + val ready = requireInitialized() ?: return StoreResult.Failure(FirestoreFailureMapper.unavailable()) + return safeRead { transport.get(name(collection, id))?.decode(serializer) } + } + + private suspend fun readUniqueEntity( + uniqueKind: String, + uniqueValue: String, + serializer: KSerializer + ): StoreResult { + requireInitialized() ?: return StoreResult.Failure(FirestoreFailureMapper.unavailable()) + return safeRead { + val key = uniqueDocumentId(uniqueKind, uniqueValue) + val claim = transport.get(name(COLLECTION_UNIQUE, key))?.decode(UniqueClaim.serializer()) ?: return@safeRead null + transport.get(name(claim.collection, claim.entityId))?.decode(serializer) + } + } + + private suspend fun queryEntities( + collection: String, + serializer: KSerializer, + field: String, + value: String, + sort: (T) -> String + ): StoreResult> { + requireInitialized() ?: return StoreResult.Failure(FirestoreFailureMapper.unavailable()) + return safeRead { + transport.runQuery(parentName(), equalityQuery(collection, field, value)) + .map { it.decode(serializer) }.sortedBy(sort) + } + } + + private suspend fun safeRead(block: suspend () -> T): StoreResult = try { + StoreResult.Success(block()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: FirestoreStoreException) { + StoreResult.Failure(failure.safeError) + } catch (_: SerializationException) { + StoreResult.Failure(FirestoreFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + StoreResult.Failure(FirestoreFailureMapper.internal()) + } catch (_: Throwable) { + StoreResult.Failure(FirestoreFailureMapper.internal()) + } + + private suspend fun requireInitialized(): Unit? = initializationMutex.withLock { + if (initialized) Unit else null + } + + private suspend fun atomicWebAuthn( + challengeId: ChallengeId, + expectedChallengeVersion: Long, + attemptedAt: Instant, + rejectionAuditEvent: AuditEvent, + block: suspend Transaction.() -> T + ): StoreResult> = atomic { + val challenge = requireChallenge(challengeId, expectedChallengeVersion, attemptedAt) + requireAuditAvailable(rejectionAuditEvent) + val writeCheckpoint = writes.size + + fun reject(error: IdentityStoreError): WebAuthnCeremonyAttemptCommit { + if (error.code == IdentityStoreErrorCode.UNAVAILABLE || error.code == IdentityStoreErrorCode.INTERNAL) { + throw StoreAbort(error) + } + while (writes.size > writeCheckpoint) writes.removeAt(writes.lastIndex) + val failed = challenge.value.copy( + state = ChallengeState.FAILED, + attemptCount = challenge.value.attemptCount + 1, + version = challenge.value.version + 1, + consumedAt = attemptedAt + ) + update(challenge, failed, Challenge.serializer(), challengeFields(failed)) + appendAudit(rejectionAuditEvent) + return WebAuthnCeremonyAttemptCommit.rejected( + WebAuthnCeremonyRejectionCommit( + challenge = failed, + error = IdentityStoreError(error.code), + auditEvent = rejectionAuditEvent + ) + ) + } + + try { + WebAuthnCeremonyAttemptCommit.completed(block()) + } catch (abort: StoreAbort) { + reject(abort.error) + } catch (_: IllegalArgumentException) { + reject(IdentityStoreError(IdentityStoreErrorCode.INVALID_TRANSITION)) + } + } + + private suspend fun atomic(block: suspend Transaction.() -> T): StoreResult { + requireInitialized() ?: return StoreResult.Failure(FirestoreFailureMapper.unavailable()) + var lastFailure: IdentityStoreError = FirestoreFailureMapper.versionConflict() + repeat(config.maximumTransactionAttempts) { attempt -> + val token = try { + transport.beginTransaction() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: FirestoreStoreException) { + lastFailure = failure.safeError + if (failure.transactionRetryable && attempt + 1 < config.maximumTransactionAttempts) return@repeat + return StoreResult.Failure(failure.safeError) + } catch (_: Throwable) { + return StoreResult.Failure(FirestoreFailureMapper.internal()) + } + val transaction = Transaction(token) + try { + val value = transaction.block() + if (transaction.writes.isEmpty()) { + transport.rollback(token) + } else { + if (transaction.writes.size > MAXIMUM_TRANSACTION_WRITES) { + throw StoreAbort(FirestoreFailureMapper.internal()) + } + transport.commit(token, transaction.writes) + } + return StoreResult.Success(value) + } catch (cancelled: CancellationException) { + transport.rollback(token) + throw cancelled + } catch (abort: StoreAbort) { + transport.rollback(token) + return StoreResult.Failure(abort.error) + } catch (failure: FirestoreStoreException) { + transport.rollback(token) + lastFailure = failure.safeError + if (failure.transactionRetryable && attempt + 1 < config.maximumTransactionAttempts) return@repeat + return StoreResult.Failure(failure.safeError) + } catch (_: SerializationException) { + transport.rollback(token) + return StoreResult.Failure(FirestoreFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + transport.rollback(token) + return StoreResult.Failure(FirestoreFailureMapper.internal()) + } catch (_: Throwable) { + transport.rollback(token) + return StoreResult.Failure(FirestoreFailureMapper.internal()) + } + } + return StoreResult.Failure(lastFailure) + } + + private inner class Transaction(val token: String) { + val writes = mutableListOf() + + suspend fun get(collection: String, id: String, serializer: KSerializer): EntityDocument? { + val documentName = name(collection, id) + val document = transport.batchGet(listOf(documentName), token)[documentName] ?: return null + return EntityDocument(collection, id, document.decode(serializer), requireNotNull(document.updateTime)) + } + + suspend fun requireEntity(collection: String, id: String, serializer: KSerializer): EntityDocument = + get(collection, id, serializer) ?: abort(IdentityStoreErrorCode.NOT_FOUND) + + suspend fun requireAbsent(collection: String, id: String) { + if (transport.batchGet(listOf(name(collection, id)), token)[name(collection, id)] != null) { + abort(IdentityStoreErrorCode.ALREADY_EXISTS) + } + } + + suspend fun query( + collection: String, + serializer: KSerializer, + field: String, + value: String + ): List> = transport.runQuery(parentName(), equalityQuery(collection, field, value), token).map { + EntityDocument(collection, it.name.substringAfterLast('/'), it.decode(serializer), requireNotNull(it.updateTime)) + } + + suspend fun hasAny(collection: String): Boolean = transport.runQuery( + parentName(), + FirestoreStructuredQuery( + from = listOf(FirestoreCollectionSelector(collection)), + limit = 1 + ), + token + ).isNotEmpty() + + suspend fun auditEventsBefore( + command: PurgeAuditEventsCommand + ): List> = transport.runQuery( + parentName(), + auditRetentionQuery(command), + token + ).map { document -> + EntityDocument( + COLLECTION_AUDIT_EVENTS, + document.name.substringAfterLast('/'), + document.decode(AuditEvent.serializer()), + requireNotNull(document.updateTime) + ) + } + + fun create( + collection: String, + id: String, + value: T, + serializer: KSerializer, + fields: Map = emptyMap() + ) { + writes += FirestoreWrite( + update = encodeDocument(collection, id, value, serializer, fields), + currentDocument = FirestorePrecondition(exists = false) + ) + } + + fun update( + current: EntityDocument, + replacement: T, + serializer: KSerializer, + fields: Map = emptyMap() + ) { + writes += FirestoreWrite( + update = encodeDocument(current.collection, current.id, replacement, serializer, fields), + currentDocument = FirestorePrecondition(updateTime = current.updateTime) + ) + } + + fun delete(current: EntityDocument<*>) { + writes += FirestoreWrite( + delete = name(current.collection, current.id), + currentDocument = FirestorePrecondition(updateTime = current.updateTime) + ) + } + + suspend fun claimUnique(kind: String, value: String, collection: String, id: String) { + val key = uniqueDocumentId(kind, value) + if (get(COLLECTION_UNIQUE, key, UniqueClaim.serializer()) != null) { + abort(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + } + create( + COLLECTION_UNIQUE, + key, + UniqueClaim(kind, collection, id), + UniqueClaim.serializer(), + mapOf(FIELD_KIND to kind, FIELD_ENTITY_ID to id) + ) + } + + suspend fun claimDeviceToken( + selectorKind: String, + selector: String, + digest: SecretDigest, + collection: String, + id: String + ) { + claimUnique(selectorKind, selector, collection, id) + claimUnique(UNIQUE_DEVICE_TOKEN_SELECTOR, selector, collection, id) + claimUnique(UNIQUE_DEVICE_TOKEN_DIGEST, digestUniqueValue(digest), collection, id) + } + + suspend fun releaseUnique(kind: String, value: String, collection: String, id: String) { + val key = uniqueDocumentId(kind, value) + val claim = get(COLLECTION_UNIQUE, key, UniqueClaim.serializer()) ?: return + if (claim.value.collection != collection || claim.value.entityId != id) abort(IdentityStoreErrorCode.INTERNAL) + delete(claim) + } + + suspend fun requireAuditAvailable(event: AuditEvent) = requireAbsent(COLLECTION_AUDIT_EVENTS, event.id.value) + + fun appendAudit(event: AuditEvent) = create( + COLLECTION_AUDIT_EVENTS, + event.id.value, + event, + AuditEvent.serializer(), + auditFields(event) + ) + + suspend fun requireChallenge(id: ChallengeId, version: Long, at: Instant): EntityDocument { + val challenge = requireEntity(COLLECTION_CHALLENGES, id.value, Challenge.serializer()) + if (challenge.value.state != ChallengeState.PENDING) abort(IdentityStoreErrorCode.CHALLENGE_NOT_PENDING) + if (at >= challenge.value.expiresAt) abort(IdentityStoreErrorCode.CHALLENGE_EXPIRED) + requireVersion(challenge.value.version, version) + return challenge + } + + suspend fun federationProviderByRoute( + organizationId: OrganizationId, + providerId: String + ): EntityDocument? { + val claim = uniqueClaim( + UNIQUE_FEDERATION_PROVIDER_ROUTE, + federationProviderRouteUniqueValue(organizationId, providerId) + ) ?: return null + requireFederationProviderClaim(claim, UNIQUE_FEDERATION_PROVIDER_ROUTE) + val control = get( + COLLECTION_FEDERATION_PROVIDER_CONTROLS, + claim.value.entityId, + FederationProviderControl.serializer() + ) ?: abort(IdentityStoreErrorCode.INTERNAL) + if (control.value.organizationId != organizationId || control.value.providerId != providerId) { + abort(IdentityStoreErrorCode.INTERNAL) + } + return control + } + + suspend fun federationProviderByStorageKey( + storageKey: String + ): EntityDocument? { + val direct = get( + COLLECTION_FEDERATION_PROVIDER_CONTROLS, + storageKey, + FederationProviderControl.serializer() + ) + val claim = uniqueClaim(UNIQUE_FEDERATION_PROVIDER_STORAGE_KEY, storageKey) + if (direct == null && claim == null) return null + if (direct == null || claim == null) abort(IdentityStoreErrorCode.INTERNAL) + requireFederationProviderClaim(claim, UNIQUE_FEDERATION_PROVIDER_STORAGE_KEY) + if (claim.value.entityId != storageKey || direct.value.storageKey != storageKey) { + abort(IdentityStoreErrorCode.INTERNAL) + } + return direct + } + + private suspend fun uniqueClaim(kind: String, value: String): EntityDocument? = + get(COLLECTION_UNIQUE, uniqueDocumentId(kind, value), UniqueClaim.serializer()) + + private fun requireFederationProviderClaim(claim: EntityDocument, expectedKind: String) { + if (claim.value.collection != COLLECTION_FEDERATION_PROVIDER_CONTROLS || + claim.value.kind != expectedKind + ) abort(IdentityStoreErrorCode.INTERNAL) + } + + suspend fun claimFederationProviderUniqueness(control: FederationProviderControl) { + claimUnique( + UNIQUE_FEDERATION_PROVIDER_ROUTE, + federationProviderRouteUniqueValue(control.organizationId, control.providerId), + COLLECTION_FEDERATION_PROVIDER_CONTROLS, + control.storageKey + ) + claimUnique( + UNIQUE_FEDERATION_PROVIDER_STORAGE_KEY, + control.storageKey, + COLLECTION_FEDERATION_PROVIDER_CONTROLS, + control.storageKey + ) + } + + fun createFederationProviderControl(control: FederationProviderControl) = create( + COLLECTION_FEDERATION_PROVIDER_CONTROLS, + control.storageKey, + control, + FederationProviderControl.serializer(), + federationProviderControlFields(control) + ) + + suspend fun requireFederationProviderLease(lease: FederationProviderLease) { + val current = federationProviderByStorageKey(lease.storageKey) + ?: abort(IdentityStoreErrorCode.NOT_FOUND) + if (current.value.state != FederationProviderState.ENABLED || current.value.lease() != lease) { + abort(IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED) + } + requireFederationProviderRouteMapping(current) + } + + private suspend fun requireFederationProviderRouteMapping( + control: EntityDocument + ) { + val route = federationProviderByRoute( + control.value.organizationId, + control.value.providerId + ) ?: abort(IdentityStoreErrorCode.INTERNAL) + if (route.id != control.id || route.value != control.value) { + abort(IdentityStoreErrorCode.INTERNAL) + } + } + + suspend fun requireChallengeFederationLease( + challenge: Challenge, + commandLease: FederationProviderLease? + ) { + if (challenge.federationProviderLease != commandLease) { + abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + commandLease?.let { requireFederationProviderLease(it) } + } + + suspend fun requireFederatedSessionProvider(session: IdentitySession) { + val storageKey = session.federationProviderKey ?: return + val current = federationProviderByStorageKey(storageKey) + ?: abort(IdentityStoreErrorCode.NOT_FOUND) + requireFederationProviderRouteMapping(current) + val expectedKind = when (session.authenticationMethod) { + SessionAuthenticationMethod.OIDC -> FederationProviderKind.OIDC + SessionAuthenticationMethod.SAML -> FederationProviderKind.SAML + else -> abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + if (current.value.state != FederationProviderState.ENABLED || + current.value.organizationId != session.federationOrganizationId || + current.value.kind != expectedKind || + current.value.sessionEpoch != session.federationProviderSessionEpoch + ) abort(IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED) + } + + suspend fun requireNewSession(session: IdentitySession, user: User) { + requireAbsent(COLLECTION_SESSIONS, session.id.value) + if (session.state != SessionState.ACTIVE || session.version != 0L || session.userId != user.id || + session.userSessionEpoch != user.sessionEpoch || user.state != UserState.ACTIVE + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + requireFederatedSessionProvider(session) + claimUnique(UNIQUE_SESSION_DIGEST, digestUniqueValue(session.tokenDigest), COLLECTION_SESSIONS, session.id.value) + } + + fun createSessionValue(session: IdentitySession) = create( + COLLECTION_SESSIONS, + session.id.value, + session, + IdentitySession.serializer(), + sessionFields(session) + ) + + suspend fun requireActiveSession( + id: SessionId, + version: Long, + at: Instant, + validateFederationProvider: Boolean = false + ): EntityDocument { + val session = requireEntity(COLLECTION_SESSIONS, id.value, IdentitySession.serializer()) + if (session.value.state != SessionState.ACTIVE) abort(IdentityStoreErrorCode.SESSION_NOT_ACTIVE) + if (validateFederationProvider) requireFederatedSessionProvider(session.value) + if (at >= session.value.idleExpiresAt || at >= session.value.absoluteExpiresAt) { + abort(IdentityStoreErrorCode.SESSION_EXPIRED) + } + requireVersion(session.value.version, version) + return session + } + + suspend fun replaceUserEmailClaim(current: User, replacement: User) { + val previous = current.primaryEmail?.let(::normalizeEmail) + val next = replacement.primaryEmail?.let(::normalizeEmail) + if (previous == next) return + previous?.let { releaseUnique(UNIQUE_EMAIL, it, COLLECTION_USERS, current.id.value) } + next?.let { claimUnique(UNIQUE_EMAIL, it, COLLECTION_USERS, replacement.id.value) } + } + + suspend fun requireReplayAvailable(receipt: ExternalIdentityReplayReceipt) { + if (get( + COLLECTION_REPLAY_RECEIPTS, + receipt.id.value, + ExternalIdentityReplayReceipt.serializer() + ) != null + ) { + abort(IdentityStoreErrorCode.REPLAY_DETECTED) + } + val assertion = receipt.provider + "\u0000" + digestUniqueValue(receipt.assertionDigest) + val claimId = uniqueDocumentId(UNIQUE_REPLAY_ASSERTION, assertion) + if (get(COLLECTION_UNIQUE, claimId, UniqueClaim.serializer()) != null) { + abort(IdentityStoreErrorCode.REPLAY_DETECTED) + } + create( + COLLECTION_UNIQUE, + claimId, + UniqueClaim( + kind = UNIQUE_REPLAY_ASSERTION, + collection = COLLECTION_REPLAY_RECEIPTS, + entityId = receipt.id.value + ), + UniqueClaim.serializer(), + mapOf(FIELD_KIND to UNIQUE_REPLAY_ASSERTION, FIELD_ENTITY_ID to receipt.id.value) + ) + } + + fun createReplayReceipt(receipt: ExternalIdentityReplayReceipt) = create( + COLLECTION_REPLAY_RECEIPTS, + receipt.id.value, + receipt, + ExternalIdentityReplayReceipt.serializer(), + mapOf(FIELD_PROVIDER to receipt.provider) + ) + + suspend fun upsertScimUser(user: User) { + val existing = get(COLLECTION_USERS, user.id.value, User.serializer()) + if (existing == null) { + if (user.version != 0L) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + user.primaryEmail?.let { claimUnique(UNIQUE_EMAIL, normalizeEmail(it), COLLECTION_USERS, user.id.value) } + create(COLLECTION_USERS, user.id.value, user, User.serializer(), userFields(user)) + } else { + if (user.version != existing.value.version + 1) abortVersion() + replaceUserEmailClaim(existing.value, user) + update(existing, user, User.serializer(), userFields(user)) + } + } + + suspend fun upsertScimMembership( + membership: Membership, + enforceLastOwner: Boolean = true, + pendingUserIds: Set = emptySet() + ) { + if (membership.userId !in pendingUserIds) { + requireEntity(COLLECTION_USERS, membership.userId.value, User.serializer()) + } + requireEntity(COLLECTION_ORGANIZATIONS, membership.organizationId.value, Organization.serializer()) + val existing = get(COLLECTION_MEMBERSHIPS, membership.id.value, Membership.serializer()) + if (existing == null) { + if (membership.version != 0L) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + claimUnique( + UNIQUE_MEMBERSHIP, + membershipUniqueValue(membership.userId, membership.organizationId), + COLLECTION_MEMBERSHIPS, + membership.id.value + ) + create(COLLECTION_MEMBERSHIPS, membership.id.value, membership, Membership.serializer(), membershipFields(membership)) + } else { + if (membership.version != existing.value.version + 1 || existing.value.userId != membership.userId || + existing.value.organizationId != membership.organizationId + ) abortVersion() + val removesOwner = existing.value.state == MembershipState.ACTIVE && + existing.value.role == OrganizationRole.OWNER && + (membership.state != MembershipState.ACTIVE || membership.role != OrganizationRole.OWNER) + if (enforceLastOwner && removesOwner) { + val others = query( + COLLECTION_MEMBERSHIPS, + Membership.serializer(), + FIELD_ORGANIZATION_ID, + membership.organizationId.value + ) + if (others.none { it.value.id != membership.id && it.value.state == MembershipState.ACTIVE && + it.value.role == OrganizationRole.OWNER }) abort(IdentityStoreErrorCode.LAST_OWNER) + } + update(existing, membership, Membership.serializer(), membershipFields(membership)) + } + } + + suspend fun applyScimMutationValue( + command: ApplyScimMutationCommand, + enforceLastOwner: Boolean = true, + pendingUserIds: Set = emptySet() + ): ScimMutationCommit { + val receipt = get( + COLLECTION_SCIM_RECEIPTS, + command.mutation.operationId.value, + AppliedScimMutation.serializer() + ) + if (receipt != null) { + if (receipt.value.command != command) abort(IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT) + return receipt.value.commit.copy(alreadyApplied = true, auditEvent = null) + } + requireAuditAvailable(command.auditEvent) + val commit = when (command.mutation.type) { + ScimMutationType.UPSERT_USER, + ScimMutationType.DEACTIVATE_USER -> { + val user = command.mutation.user!! + if (command.mutation.type == ScimMutationType.DEACTIVATE_USER && + user.state != UserState.DEACTIVATED + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + upsertScimUser(user) + ScimMutationCommit(user = user, alreadyApplied = false, auditEvent = command.auditEvent) + } + ScimMutationType.UPSERT_MEMBERSHIP, + ScimMutationType.REMOVE_MEMBERSHIP -> { + val membership = command.mutation.membership!! + if (command.mutation.type == ScimMutationType.REMOVE_MEMBERSHIP && + membership.state != MembershipState.REMOVED + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + upsertScimMembership(membership, enforceLastOwner, pendingUserIds) + ScimMutationCommit(membership = membership, alreadyApplied = false, auditEvent = command.auditEvent) + } + } + appendAudit(command.auditEvent) + create( + COLLECTION_SCIM_RECEIPTS, + command.mutation.operationId.value, + AppliedScimMutation(command, commit), + AppliedScimMutation.serializer(), + mapOf(FIELD_PROVIDER to command.mutation.provider) + ) + return commit + } + + suspend fun validateScimBatchLastOwner(command: ApplyScimBatchCommand) { + val replacements = command.mutations.mapNotNull { it.mutation.membership } + if (replacements.isEmpty()) return + val current = query( + COLLECTION_MEMBERSHIPS, + Membership.serializer(), + FIELD_ORGANIZATION_ID, + command.organizationId.value + ).map { it.value } + val prospective = current.associateBy { it.id }.toMutableMap() + replacements.forEach { prospective[it.id] = it } + val currentOwners = current.count { + it.state == MembershipState.ACTIVE && it.role == OrganizationRole.OWNER + } + val prospectiveOwners = prospective.values.count { + it.state == MembershipState.ACTIVE && it.role == OrganizationRole.OWNER + } + if (currentOwners > 0 && prospectiveOwners == 0) abort(IdentityStoreErrorCode.LAST_OWNER) + } + } + + private suspend fun uniqueDocumentId(kind: String, value: String): String = + "$kind-${Base64Url.encode(runtime.crypto.sha256(value.encodeToByteArray()))}" + + private fun encodeDocument( + collection: String, + id: String, + value: T, + serializer: KSerializer, + indexedFields: Map + ): FirestoreDocument { + val payload = json.encodeToString(serializer, value) + require(payload.encodeToByteArray().size <= config.maximumRequestBytes) { "Firestore entity payload exceeds limit" } + val fields = mutableMapOf( + FIELD_PAYLOAD to stringValue(payload), + FIELD_ENTITY_ID to stringValue(id), + FIELD_ENVIRONMENT to stringValue(config.environment.wireName), + FIELD_NAMESPACE to stringValue(config.namespace), + FIELD_SCHEMA_VERSION to integerValue(FIRESTORE_SCHEMA_VERSION.toLong()) + ) + indexedFields.forEach { (key, fieldValue) -> + fields[key] = if (collection == COLLECTION_AUDIT_EVENTS && key == FIELD_OCCURRED_AT) { + timestampValue(fieldValue) + } else { + stringValue(fieldValue) + } + } + return FirestoreDocument(name(collection, id), fields) + } + + private fun FirestoreDocument.decode(serializer: KSerializer): T { + if (fields[FIELD_ENVIRONMENT]?.stringValue != config.environment.wireName || + fields[FIELD_NAMESPACE]?.stringValue != config.namespace || + fields[FIELD_SCHEMA_VERSION]?.integerValue?.toIntOrNull() != FIRESTORE_SCHEMA_VERSION + ) throw FirestoreStoreException(FirestoreFailureMapper.internal()) + val payload = fields[FIELD_PAYLOAD]?.stringValue ?: throw FirestoreStoreException(FirestoreFailureMapper.internal()) + if (payload.encodeToByteArray().size > config.maximumResponseBytes) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } + return json.decodeFromString(serializer, payload) + } + + private fun equalityQuery(collection: String, field: String, value: String): FirestoreStructuredQuery = + FirestoreStructuredQuery( + from = listOf(FirestoreCollectionSelector(collection)), + where = FirestoreFilter( + fieldFilter = FirestoreFieldFilter( + FirestoreFieldReference(field), + op = "EQUAL", + value = stringValue(value) + ) + ) + ) + + private fun auditPageQuery(request: OrganizationAuditEventPageRequest): FirestoreStructuredQuery = + FirestoreStructuredQuery( + from = listOf(FirestoreCollectionSelector(COLLECTION_AUDIT_EVENTS)), + where = FirestoreFilter( + fieldFilter = FirestoreFieldFilter( + field = FirestoreFieldReference(FIELD_ORGANIZATION_ID), + op = "EQUAL", + value = stringValue(request.organizationId.value) + ) + ), + orderBy = listOf( + FirestoreOrder(FirestoreFieldReference(FIELD_OCCURRED_AT), direction = "DESCENDING"), + FirestoreOrder(FirestoreFieldReference(FIELD_ENTITY_ID), direction = "DESCENDING") + ), + startAt = request.cursor?.let { cursor -> + FirestoreCursor( + values = listOf( + timestampValue(cursor.occurredAt.toString()), + stringValue(cursor.id.value) + ), + before = false + ) + }, + limit = request.limit + 1 + ) + + private fun auditRetentionQuery(command: PurgeAuditEventsCommand): FirestoreStructuredQuery = + FirestoreStructuredQuery( + from = listOf(FirestoreCollectionSelector(COLLECTION_AUDIT_EVENTS)), + where = FirestoreFilter( + fieldFilter = FirestoreFieldFilter( + field = FirestoreFieldReference(FIELD_OCCURRED_AT), + op = "LESS_THAN", + value = timestampValue(command.occurredBefore.toString()) + ) + ), + orderBy = listOf( + FirestoreOrder(FirestoreFieldReference(FIELD_OCCURRED_AT), direction = "ASCENDING"), + FirestoreOrder(FirestoreFieldReference(FIELD_ENTITY_ID), direction = "ASCENDING") + ), + limit = command.maximumEvents + 1 + ) + + private fun name(collection: String, id: String): String { + require(COLLECTION_ID.matches(collection) && DOCUMENT_ID.matches(id)) { "Invalid Firestore document path" } + return "projects/${config.projectId}/databases/${config.databaseId}/documents/${config.namespaceDocument}/$collection/$id" + } + + private fun parentName(): String = + "projects/${config.projectId}/databases/${config.databaseId}/documents/${config.namespaceDocument}" + + private fun userFields(value: User): Map = buildMap { + put(FIELD_STATE, value.state.name.lowercase()) + value.primaryEmail?.let { put(FIELD_NORMALIZED_EMAIL, normalizeEmail(it)) } + } + + private fun credentialFields(value: Credential) = mapOf( + FIELD_USER_ID to value.userId.value, + FIELD_STATE to value.state.name.lowercase(), + FIELD_CREATED_AT to value.createdAt.toString() + ) + + private fun sessionFields(value: IdentitySession) = buildMap { + put(FIELD_USER_ID, value.userId.value) + put(FIELD_STATE, value.state.name.lowercase()) + put(FIELD_LAST_USED_AT, value.lastUsedAt.toString()) + value.federationOrganizationId?.let { put(FIELD_ORGANIZATION_ID, it.value) } + value.federationProviderKey?.let { put(FIELD_PROVIDER, it) } + value.federationProviderSessionEpoch?.let { put(FIELD_PROVIDER_SESSION_EPOCH, it.toString()) } + } + + private fun federationProviderControlFields(value: FederationProviderControl) = mapOf( + FIELD_ORGANIZATION_ID to value.organizationId.value, + FIELD_PROVIDER_ID to value.providerId, + FIELD_PROVIDER_KIND to value.kind.name.lowercase(), + FIELD_STORAGE_KEY to value.storageKey, + FIELD_STATE to value.state.name.lowercase(), + FIELD_PROVIDER_SESSION_EPOCH to value.sessionEpoch.toString(), + FIELD_VERSION to value.version.toString(), + FIELD_UPDATED_AT to value.updatedAt.toString() + ) + + private fun organizationFields(value: Organization) = mapOf( + FIELD_SLUG to value.slug, + FIELD_STATE to value.state.name.lowercase() + ) + + private fun membershipFields(value: Membership) = mapOf( + FIELD_USER_ID to value.userId.value, + FIELD_ORGANIZATION_ID to value.organizationId.value, + FIELD_STATE to value.state.name.lowercase(), + FIELD_ROLE to value.role.wireName + ) + + private fun invitationFields(value: Invitation) = mapOf( + FIELD_ORGANIZATION_ID to value.organizationId.value, + FIELD_NORMALIZED_EMAIL to normalizeEmail(value.email), + FIELD_STATE to value.state.name.lowercase() + ) + + private fun serviceIdentityFields(value: ServiceIdentity) = mapOf( + FIELD_ORGANIZATION_ID to value.organizationId.value, + FIELD_STATE to value.state.name.lowercase() + ) + + private fun serviceCredentialFields(value: ServiceCredential) = mapOf( + FIELD_SERVICE_IDENTITY_ID to value.serviceIdentityId.value, + FIELD_PUBLIC_PREFIX to value.publicPrefix, + FIELD_STATE to value.state.name.lowercase() + ) + + private fun scimGroupFields(value: ScimGroup) = mapOf( + FIELD_ORGANIZATION_ID to value.organizationId.value, + FIELD_PROVIDER to value.provider, + FIELD_STATE to value.state.name.lowercase() + ) + + private fun externalIdentityFields(value: ExternalIdentity) = mapOf( + FIELD_USER_ID to value.userId.value, + FIELD_PROVIDER to value.provider, + FIELD_STATE to value.state.name.lowercase() + ) + + private fun challengeFields(value: Challenge) = buildMap { + put(FIELD_STATE, value.state.name.lowercase()) + value.userId?.let { put(FIELD_USER_ID, it.value) } + value.organizationId?.let { put(FIELD_ORGANIZATION_ID, it.value) } + } + + private fun recoveryFields(value: RecoveryCode) = mapOf( + FIELD_USER_ID to value.userId.value, + FIELD_GENERATION to value.generation.toString(), + FIELD_STATE to value.state.name.lowercase() + ) + + private fun deviceGrantFields(value: DeviceGrant) = mapOf( + FIELD_STATE to value.state.name.lowercase(), + FIELD_CREATED_AT to value.createdAt.toString() + ) + + private fun deviceTokenFamilyFields(value: DeviceTokenFamily) = mapOf( + FIELD_USER_ID to value.userId.value, + FIELD_ORGANIZATION_ID to value.organizationId.value, + FIELD_STATE to value.state.name.lowercase(), + FIELD_CREATED_AT to value.createdAt.toString() + ) + + private fun deviceAccessTokenFields(value: DeviceAccessToken) = mapOf( + FIELD_FAMILY_ID to value.familyId.value, + FIELD_STATE to value.state.name.lowercase(), + FIELD_CREATED_AT to value.createdAt.toString() + ) + + private fun deviceRefreshTokenFields(value: DeviceRefreshToken) = mapOf( + FIELD_FAMILY_ID to value.familyId.value, + FIELD_STATE to value.state.name.lowercase(), + FIELD_CREATED_AT to value.createdAt.toString() + ) + + private fun auditFields(value: AuditEvent) = buildMap { + put(FIELD_OCCURRED_AT, value.occurredAt.toString()) + put(FIELD_ACTION, value.action.name.lowercase()) + value.organizationId?.let { put(FIELD_ORGANIZATION_ID, it.value) } + } + + private fun normalizeEmail(value: EmailAddress): String = value.value.lowercase() + private fun membershipUniqueValue(userId: UserId, organizationId: OrganizationId) = + "${organizationId.value}\u0000${userId.value}" + private fun federationProviderRouteUniqueValue(organizationId: OrganizationId, providerId: String) = + "${organizationId.value}\u0000$providerId" + private fun pendingInvitationUniqueValue(organizationId: OrganizationId, email: EmailAddress) = + "${organizationId.value}\u0000${normalizeEmail(email)}" + private fun externalUniqueValue(provider: String, subject: ExternalSubject) = + "$provider\u0000${subject.value}" + private fun digestUniqueValue(value: SecretDigest): String = + "${value.algorithm.name}\u0000${value.keyVersion.orEmpty()}\u0000${value.encoded}" + + private fun FederationProviderControl.lease(): FederationProviderLease = FederationProviderLease( + organizationId = organizationId, + kind = kind, + providerId = providerId, + storageKey = storageKey, + sessionEpoch = sessionEpoch, + version = version + ) + + private fun FederationProviderControl.matches( + command: AcquireFederationProviderLeaseCommand + ): Boolean = organizationId == command.organizationId && kind == command.kind && + providerId == command.providerId && storageKey == command.storageKey + + private fun FederationProviderControl.hasSameIdentity( + other: FederationProviderControl + ): Boolean = organizationId == other.organizationId && kind == other.kind && + providerId == other.providerId && storageKey == other.storageKey + + private fun consumeChallengeValue(value: Challenge, at: Instant): Challenge = value.copy( + state = ChallengeState.CONSUMED, + version = value.version + 1, + consumedAt = at + ) + + private fun rotateSessionValue(value: IdentitySession, replacement: SessionId): IdentitySession = value.copy( + state = SessionState.ROTATED, + version = value.version + 1, + rotatedToId = replacement + ) + + private fun revokeSessionValue(value: IdentitySession, at: Instant, reason: String): IdentitySession = value.copy( + state = SessionState.REVOKED, + version = value.version + 1, + revokedAt = at, + revocationReasonCode = reason + ) + + private fun requireDeviceGrantTransition(existing: DeviceGrant, replacement: DeviceGrant) { + if (existing.id != replacement.id || existing.deviceCodeDigest != replacement.deviceCodeDigest || + existing.userCodeDigest != replacement.userCodeDigest || existing.clientId != replacement.clientId || + existing.clientName != replacement.clientName || + existing.requestedCapabilities != replacement.requestedCapabilities || existing.createdAt != replacement.createdAt || + existing.expiresAt != replacement.expiresAt + ) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + val allowed = when (existing.state) { + DeviceGrantState.PENDING -> replacement.state in setOf( + DeviceGrantState.PENDING, DeviceGrantState.AUTHORIZED, DeviceGrantState.DENIED, + DeviceGrantState.EXPIRED, DeviceGrantState.CANCELLED + ) + DeviceGrantState.AUTHORIZED -> replacement.state in setOf( + DeviceGrantState.AUTHORIZED, DeviceGrantState.CONSUMED, DeviceGrantState.EXPIRED, + DeviceGrantState.CANCELLED + ) + DeviceGrantState.DENIED, DeviceGrantState.CONSUMED, DeviceGrantState.EXPIRED, + DeviceGrantState.CANCELLED -> false + } + if (!allowed) abort(IdentityStoreErrorCode.INVALID_TRANSITION) + } + + private fun requireVersion(actual: Long, expected: Long) { + if (actual != expected) abortVersion() + } + + private fun abortVersion(): Nothing = throw StoreAbort(FirestoreFailureMapper.versionConflict()) + private fun abort(code: IdentityStoreErrorCode): Nothing = throw StoreAbort(IdentityStoreError(code)) + private fun failure(code: IdentityStoreErrorCode): StoreResult.Failure = + StoreResult.Failure(IdentityStoreError(code)) + + private data class EntityDocument( + val collection: String, + val id: String, + val value: T, + val updateTime: String + ) + + private class StoreAbort(val error: IdentityStoreError) : Throwable() + + @Serializable + private data class UniqueClaim(val kind: String, val collection: String, val entityId: String) + + @Serializable + private data class AppliedScimMutation( + val command: ApplyScimMutationCommand, + val commit: ScimMutationCommit + ) + + @Serializable + private data class AppliedScimBatch( + val command: ApplyScimBatchCommand, + val commit: ScimBatchCommit + ) + + @Serializable + private data class BootstrapReceipt(val secretDigest: SecretDigest, val completedAt: Instant) + + companion object { + const val FIRESTORE_SCHEMA_VERSION: Int = 1 + const val FIRESTORE_ENVIRONMENT_MARKER_SCHEMA_VERSION: Int = 2 + private const val MAXIMUM_TRANSACTION_WRITES = 500 + private const val DOCUMENT_CURRENT = "current" + + private const val COLLECTION_BOOTSTRAP = "bootstrap" + private const val COLLECTION_USERS = "users" + private const val COLLECTION_CREDENTIALS = "credentials" + private const val COLLECTION_SESSIONS = "sessions" + private const val COLLECTION_ORGANIZATIONS = "organizations" + private const val COLLECTION_MEMBERSHIPS = "memberships" + private const val COLLECTION_INVITATIONS = "invitations" + private const val COLLECTION_SERVICE_IDENTITIES = "serviceIdentities" + private const val COLLECTION_SERVICE_CREDENTIALS = "serviceCredentials" + private const val COLLECTION_EXTERNAL_IDENTITIES = "externalIdentities" + private const val COLLECTION_FEDERATION_PROVIDER_CONTROLS = "federationProviderControls" + private const val COLLECTION_CHALLENGES = "challenges" + private const val COLLECTION_RECOVERY_CODES = "recoveryCodes" + private const val COLLECTION_DEVICE_GRANTS = "deviceGrants" + private const val COLLECTION_DEVICE_TOKEN_FAMILIES = "deviceTokenFamilies" + private const val COLLECTION_DEVICE_ACCESS_TOKENS = "deviceAccessTokens" + private const val COLLECTION_DEVICE_REFRESH_TOKENS = "deviceRefreshTokens" + private const val COLLECTION_REPLAY_RECEIPTS = "replayReceipts" + private const val COLLECTION_AUDIT_EVENTS = "auditEvents" + private const val COLLECTION_SCIM_GROUPS = "scimGroups" + private const val COLLECTION_SCIM_RECEIPTS = "scimReceipts" + private const val COLLECTION_SCIM_BATCH_RECEIPTS = "scimBatchReceipts" + private const val COLLECTION_UNIQUE = "unique" + + private const val UNIQUE_EMAIL = "email" + private const val UNIQUE_WEBAUTHN_ID = "webauthn-id" + private const val UNIQUE_MEMBERSHIP = "membership" + private const val UNIQUE_ORGANIZATION_SLUG = "organization-slug" + private const val UNIQUE_INVITATION_DIGEST = "invitation-digest" + private const val UNIQUE_PENDING_INVITATION = "pending-invitation" + private const val UNIQUE_SERVICE_PREFIX = "service-prefix" + private const val UNIQUE_SERVICE_DIGEST = "service-digest" + private const val UNIQUE_EXTERNAL_IDENTITY = "external-identity" + private const val UNIQUE_FEDERATION_PROVIDER_ROUTE = "federation-provider-route" + private const val UNIQUE_FEDERATION_PROVIDER_STORAGE_KEY = "federation-provider-storage-key" + private const val UNIQUE_RECOVERY_SELECTOR = "recovery-selector" + private const val UNIQUE_RECOVERY_DIGEST = "recovery-digest" + private const val UNIQUE_CHALLENGE_DIGEST = "challenge-digest" + private const val UNIQUE_SESSION_DIGEST = "session-digest" + private const val UNIQUE_DEVICE_CODE = "device-code" + private const val UNIQUE_USER_CODE = "user-code" + private const val UNIQUE_DEVICE_ACCESS_SELECTOR = "device-access-selector" + private const val UNIQUE_DEVICE_REFRESH_SELECTOR = "device-refresh-selector" + private const val UNIQUE_DEVICE_TOKEN_SELECTOR = "device-token-selector" + private const val UNIQUE_DEVICE_TOKEN_DIGEST = "device-token-digest" + private const val UNIQUE_REPLAY_ASSERTION = "replay-assertion" + + private const val FIELD_PAYLOAD = "payload" + private const val FIELD_ENTITY_ID = "entityId" + private const val FIELD_SLUG = "slug" + private const val FIELD_ENVIRONMENT = "environment" + private const val FIELD_NAMESPACE = "namespace" + private const val FIELD_SCHEMA_VERSION = "schemaVersion" + private const val FIELD_KIND = "kind" + private const val FIELD_STATE = "state" + private const val FIELD_USER_ID = "userId" + private const val FIELD_ORGANIZATION_ID = "organizationId" + private const val FIELD_SERVICE_IDENTITY_ID = "serviceIdentityId" + private const val FIELD_PROVIDER = "provider" + private const val FIELD_PROVIDER_ID = "providerId" + private const val FIELD_PROVIDER_KIND = "providerKind" + private const val FIELD_STORAGE_KEY = "storageKey" + private const val FIELD_PROVIDER_SESSION_EPOCH = "providerSessionEpoch" + private const val FIELD_VERSION = "version" + private const val FIELD_UPDATED_AT = "updatedAt" + private const val FIELD_PUBLIC_PREFIX = "publicPrefix" + private const val FIELD_NORMALIZED_EMAIL = "normalizedEmail" + private const val FIELD_GENERATION = "generation" + private const val FIELD_ROLE = "role" + private const val FIELD_CREATED_AT = "createdAt" + private const val FIELD_LAST_USED_AT = "lastUsedAt" + private const val FIELD_OCCURRED_AT = "occurredAt" + private const val FIELD_ACTION = "action" + private const val FIELD_FAMILY_ID = "familyId" + + private val COLLECTION_ID = Regex("[A-Za-z][A-Za-z0-9]{0,127}") + private val DOCUMENT_ID = Regex("[^/]{1,1500}") + } +} diff --git a/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreOAuth.kt b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreOAuth.kt new file mode 100644 index 0000000..027411e --- /dev/null +++ b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreOAuth.kt @@ -0,0 +1,73 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.IdentityClock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Short-lived OAuth bearer token. Its value is deliberately absent from logs and serialization. */ +class FirestoreAccessToken( + token: String, + val expiresAt: Instant +) { + private val value = token + + init { + require(token.isNotBlank() && token.length <= 16_384) { "Invalid Firestore OAuth access token" } + } + + internal fun authorizationHeader(): String = "Bearer $value" + override fun toString(): String = "FirestoreAccessToken(value=, expiresAt=$expiresAt)" +} + +/** Exchanges workload identity, metadata-server, or service-account credentials for an access token. */ +fun interface FirestoreOAuthCredentialSource { + suspend fun refreshAccessToken(): FirestoreAccessToken +} + +fun interface FirestoreAccessTokenProvider { + /** Returns null only for an explicitly configured local emulator. */ + suspend fun accessToken(): FirestoreAccessToken? + + /** Drops any cached bearer after Firestore rejects it. Stateless providers may do nothing. */ + suspend fun invalidate() = Unit +} + +/** Coroutine-safe token cache which refreshes before expiry and never serializes credential state. */ +class RefreshingFirestoreAccessTokenProvider( + private val clock: IdentityClock, + private val credentialSource: FirestoreOAuthCredentialSource, + private val refreshSkew: Duration = DEFAULT_REFRESH_SKEW +) : FirestoreAccessTokenProvider { + private val mutex = Mutex() + private var cached: FirestoreAccessToken? = null + + init { + require(refreshSkew >= Duration.ZERO && refreshSkew <= 10.minutesCompat()) { + "Invalid Firestore OAuth refresh skew" + } + } + + override suspend fun accessToken(): FirestoreAccessToken = mutex.withLock { + val now = clock.now() + cached?.takeIf { now + refreshSkew < it.expiresAt } ?: credentialSource.refreshAccessToken().also { + require(it.expiresAt > now + refreshSkew) { "Refreshed Firestore OAuth token expires too soon" } + cached = it + } + } + + override suspend fun invalidate() = mutex.withLock { cached = null } + + companion object { + val DEFAULT_REFRESH_SKEW: Duration = 60.seconds + } +} + +/** Explicit no-auth provider for the loopback Firestore emulator only. */ +object FirestoreEmulatorAccessTokenProvider : FirestoreAccessTokenProvider { + override suspend fun accessToken(): FirestoreAccessToken? = null +} + +private fun Int.minutesCompat(): Duration = (this * 60).seconds diff --git a/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreRestProtocol.kt b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreRestProtocol.kt new file mode 100644 index 0000000..98cdbf8 --- /dev/null +++ b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreRestProtocol.kt @@ -0,0 +1,181 @@ +package codes.yousef.aether.auth.firestore + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +internal data class FirestoreValue( + val nullValue: String? = null, + val booleanValue: Boolean? = null, + val integerValue: String? = null, + val doubleValue: Double? = null, + val timestampValue: String? = null, + val stringValue: String? = null, + val bytesValue: String? = null, + val referenceValue: String? = null, + val arrayValue: FirestoreArrayValue? = null, + val mapValue: FirestoreMapValue? = null +) + +@Serializable +internal data class FirestoreArrayValue(val values: List = emptyList()) + +@Serializable +internal data class FirestoreMapValue(val fields: Map = emptyMap()) + +@Serializable +internal data class FirestoreDocument( + val name: String, + val fields: Map, + val createTime: String? = null, + val updateTime: String? = null +) + +@Serializable +internal data class FirestorePrecondition( + val exists: Boolean? = null, + val updateTime: String? = null +) { + init { require((exists == null) != (updateTime == null)) { "A Firestore precondition requires exactly one condition" } } +} + +@Serializable +internal data class FirestoreWrite( + val update: FirestoreDocument? = null, + val delete: String? = null, + val currentDocument: FirestorePrecondition? = null +) { + init { require((update == null) != (delete == null)) { "A Firestore write requires exactly one operation" } } +} + +@Serializable +internal data class BeginTransactionRequest( + val options: FirestoreTransactionOptions = FirestoreTransactionOptions() +) + +@Serializable +internal data class FirestoreTransactionOptions( + val readWrite: FirestoreReadWriteOptions = FirestoreReadWriteOptions() +) + +@Serializable +internal class FirestoreReadWriteOptions + +@Serializable +internal data class BeginTransactionResponse(val transaction: String) + +@Serializable +internal data class BatchGetDocumentsRequest( + val documents: List, + val transaction: String? = null +) + +@Serializable +internal data class BatchGetDocumentsResponse( + val found: FirestoreDocument? = null, + val missing: String? = null, + val readTime: String? = null, + val transaction: String? = null +) + +@Serializable +internal data class RunQueryRequest( + val structuredQuery: FirestoreStructuredQuery, + val transaction: String? = null +) + +@Serializable +internal data class RunQueryResponse( + val document: FirestoreDocument? = null, + val readTime: String? = null, + val skippedResults: Int? = null, + // The official emulator terminates an empty REST stream with this protobuf JSON field. + val done: Boolean? = null +) + +@Serializable +internal data class FirestoreStructuredQuery( + val from: List, + val where: FirestoreFilter? = null, + val orderBy: List = emptyList(), + val startAt: FirestoreCursor? = null, + val limit: Int? = null +) + +@Serializable +internal data class FirestoreCursor( + val values: List, + val before: Boolean = false +) + +@Serializable +internal data class FirestoreCollectionSelector( + val collectionId: String, + val allDescendants: Boolean = false +) + +@Serializable +internal data class FirestoreFilter( + val fieldFilter: FirestoreFieldFilter? = null, + val compositeFilter: FirestoreCompositeFilter? = null +) + +@Serializable +internal data class FirestoreCompositeFilter( + val op: String, + val filters: List +) + +@Serializable +internal data class FirestoreFieldFilter( + val field: FirestoreFieldReference, + val op: String, + val value: FirestoreValue +) + +@Serializable +internal data class FirestoreFieldReference(val fieldPath: String) + +@Serializable +internal data class FirestoreOrder( + val field: FirestoreFieldReference, + val direction: String +) + +@Serializable +internal data class CommitRequest( + val writes: List, + val transaction: String? = null +) + +@Serializable +internal data class CommitResponse( + val writeResults: List = emptyList(), + val commitTime: String? = null +) + +@Serializable +internal data class FirestoreWriteResult( + val updateTime: String? = null, + val transformResults: List = emptyList() +) + +@Serializable +internal data class RollbackRequest(val transaction: String) + +@Serializable +internal data class FirestoreErrorEnvelope(val error: FirestoreProviderError? = null) + +@Serializable +internal data class FirestoreProviderError( + val code: Int? = null, + val message: String? = null, + val status: String? = null, + val details: List = emptyList() +) + +internal fun stringValue(value: String): FirestoreValue = FirestoreValue(stringValue = value) +internal fun timestampValue(value: String): FirestoreValue = FirestoreValue(timestampValue = value) +internal fun integerValue(value: Long): FirestoreValue = FirestoreValue(integerValue = value.toString()) +internal fun booleanValue(value: Boolean): FirestoreValue = FirestoreValue(booleanValue = value) diff --git a/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreRestTransport.kt b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreRestTransport.kt new file mode 100644 index 0000000..c09f24c --- /dev/null +++ b/aether-auth-firestore/src/commonMain/kotlin/codes/yousef/aether/auth/firestore/FirestoreRestTransport.kt @@ -0,0 +1,237 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.IdentityHttpResponse +import codes.yousef.aether.auth.IdentityRuntime +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement + +/** Internal document transport, implemented by the Firestore v1 REST API on every KMP target. */ +internal interface FirestoreDocumentTransport { + suspend fun get(documentName: String): FirestoreDocument? + suspend fun beginTransaction(): String + suspend fun batchGet(documentNames: List, transaction: String): Map + suspend fun runQuery(parent: String, query: FirestoreStructuredQuery, transaction: String? = null): List + suspend fun commit(transaction: String?, writes: List): CommitResponse + suspend fun rollback(transaction: String) +} + +/** Firestore v1 REST transport using injected HTTP and refreshable OAuth capabilities. */ +internal class FirestoreRestTransport( + private val config: FirestoreIdentityConfig, + private val runtime: IdentityRuntime, + private val accessTokens: FirestoreAccessTokenProvider, + private val json: Json = defaultFirestoreJson() +) : FirestoreDocumentTransport { + init { + val emulator = config.normalizedApiBaseUrl.startsWith("http://") + require(emulator || accessTokens !== FirestoreEmulatorAccessTokenProvider) { + "The no-auth Firestore token provider is allowed only for a loopback emulator" + } + } + + override suspend fun get(documentName: String): FirestoreDocument? { + val response = request(IdentityHttpMethod.GET, documentUrl(documentName), null, allowNotFound = true) + ?: return null + return decode(response) + } + + override suspend fun beginTransaction(): String { + val response = request( + IdentityHttpMethod.POST, + "${config.documentsRoot}:beginTransaction", + json.encodeToString(BeginTransactionRequest()).encodeToByteArray() + ) ?: throw FirestoreStoreException(FirestoreFailureMapper.internal()) + return decode(response).transaction.also(::requireTransactionToken) + } + + override suspend fun batchGet( + documentNames: List, + transaction: String + ): Map { + if (documentNames.isEmpty()) return emptyMap() + requireTransactionToken(transaction) + val response = request( + IdentityHttpMethod.POST, + "${config.documentsRoot}:batchGet", + json.encodeToString(BatchGetDocumentsRequest(documentNames, transaction)).encodeToByteArray() + ) ?: throw FirestoreStoreException(FirestoreFailureMapper.internal()) + val decoded = decodeStream(response) + val result = documentNames.associateWith { null as FirestoreDocument? }.toMutableMap() + decoded.forEach { item -> + item.found?.let { result[it.name] = it } + item.missing?.let { result[it] = null } + } + return result + } + + override suspend fun runQuery( + parent: String, + query: FirestoreStructuredQuery, + transaction: String? + ): List { + transaction?.let(::requireTransactionToken) + val response = request( + IdentityHttpMethod.POST, + "${documentUrl(parent)}:runQuery", + json.encodeToString(RunQueryRequest(query, transaction)).encodeToByteArray() + ) ?: throw FirestoreStoreException(FirestoreFailureMapper.internal()) + return decodeStream(response).mapNotNull { it.document } + } + + override suspend fun commit(transaction: String?, writes: List): CommitResponse { + require(writes.isNotEmpty()) { "A Firestore commit requires at least one write" } + transaction?.let(::requireTransactionToken) + val response = request( + IdentityHttpMethod.POST, + "${config.documentsRoot}:commit", + json.encodeToString(CommitRequest(writes, transaction)).encodeToByteArray() + ) ?: throw FirestoreStoreException(FirestoreFailureMapper.internal()) + return decode(response) + } + + override suspend fun rollback(transaction: String) { + requireTransactionToken(transaction) + runCatching { + request( + IdentityHttpMethod.POST, + "${config.documentsRoot}:rollback", + json.encodeToString(RollbackRequest(transaction)).encodeToByteArray() + ) + } + } + + private suspend fun request( + method: IdentityHttpMethod, + url: String, + body: ByteArray?, + allowNotFound: Boolean = false + ): ByteArray? { + if (body != null && body.size > config.maximumRequestBytes) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } + var attempt = execute(method, url, body) + if (attempt.authenticated && attempt.isAuthenticationFailure()) { + try { + accessTokens.invalidate() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + throw FirestoreStoreException(FirestoreFailureMapper.unavailable()) + } + attempt = execute(method, url, body) + } + val response = attempt.response + val responseBody = attempt.body + if (allowNotFound && response.statusCode == 404) return null + if (response.statusCode !in 200..299) { + val status = attempt.providerStatus() + val safe = FirestoreFailureMapper.fromProvider(status, response.statusCode) + throw FirestoreStoreException( + safeError = safe, + transactionRetryable = status == "ABORTED" || status == "UNAVAILABLE" || status == "DEADLINE_EXCEEDED" + ) + } + return responseBody + } + + private suspend fun execute( + method: IdentityHttpMethod, + url: String, + body: ByteArray? + ): HttpAttempt { + val headers = mutableMapOf("Accept" to "application/json") + if (body != null) headers["Content-Type"] = "application/json" + val token = try { + accessTokens.accessToken() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + throw FirestoreStoreException(FirestoreFailureMapper.unavailable()) + } + token?.let { headers["Authorization"] = it.authorizationHeader() } + val response: IdentityHttpResponse = try { + runtime.http.execute(IdentityHttpRequest(method, url, headers, body ?: ByteArray(0))) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + throw FirestoreStoreException(FirestoreFailureMapper.unavailable()) + } finally { + headers.remove("Authorization") + } + val responseBody = response.bodyBytes() + if (responseBody.size > config.maximumResponseBytes) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } + return HttpAttempt(response, responseBody, authenticated = token != null) + } + + private fun HttpAttempt.providerStatus(): String? = + runCatching { decode(body).error?.status }.getOrNull() + + private fun HttpAttempt.isAuthenticationFailure(): Boolean = + response.statusCode == 401 || providerStatus()?.equals("UNAUTHENTICATED", ignoreCase = true) == true + + private inline fun decode(bytes: ByteArray): T = try { + json.decodeFromString(bytes.decodeToString()) + } catch (_: SerializationException) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } + + private inline fun decodeStream(bytes: ByteArray): List = try { + val text = bytes.decodeToString().trim() + if (text.isEmpty()) return emptyList() + val element = json.parseToJsonElement(text) + when (element) { + is JsonArray -> element.map { json.decodeFromJsonElement(it) } + is JsonObject -> listOf(json.decodeFromJsonElement(element)) + else -> throw SerializationException("Unexpected Firestore stream shape") + } + } catch (_: SerializationException) { + // Some Google API proxies expose server-streamed responses as newline-delimited objects. + try { + bytes.decodeToString().lineSequence().filter { it.isNotBlank() } + .map { json.decodeFromString(it) }.toList() + } catch (_: Throwable) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } + } catch (_: IllegalArgumentException) { + throw FirestoreStoreException(FirestoreFailureMapper.internal()) + } + + private fun documentUrl(documentName: String): String { + require(documentName.startsWith("projects/${config.projectId}/databases/${config.databaseId}/documents/")) { + "Firestore document belongs to a different project or database" + } + return "${config.normalizedApiBaseUrl}/$documentName" + } + + private data class HttpAttempt( + val response: IdentityHttpResponse, + val body: ByteArray, + val authenticated: Boolean + ) +} + +internal fun defaultFirestoreJson(): Json = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = false + isLenient = false + allowSpecialFloatingPointValues = false + allowStructuredMapKeys = false +} + +private fun requireTransactionToken(value: String) { + require(value.isNotBlank() && value.length <= 16_384 && value.none(Char::isWhitespace)) { + "Invalid Firestore transaction token" + } +} diff --git a/aether-auth-firestore/src/commonMain/resources/aether-identity/README.md b/aether-auth-firestore/src/commonMain/resources/aether-identity/README.md new file mode 100644 index 0000000..1788089 --- /dev/null +++ b/aether-auth-firestore/src/commonMain/resources/aether-identity/README.md @@ -0,0 +1,77 @@ +# Firestore identity storage + +The adapter stores records below the namespace document +`aetherIdentity/{namespace}` in the configured Firestore database. Entity documents contain a +versioned JSON payload plus a small set of typed shadow fields used by reviewed queries. The +payload field must remain unindexed in every identity collection, including internal bootstrap, +uniqueness, replay, token, and SCIM receipt collections; deploy `firestore.indexes.json` with the +application. The JVM resource contract test compares the shipped exemptions with every collection +declared by the adapter so a newly added payload collection cannot silently retain default indexes. + +Values which must be globally unique are represented by deterministic, SHA-256-derived documents +under `unique/`. Email addresses, WebAuthn credential IDs, membership pairs, session digests, +recovery selectors, device/user-code digests, service-credential prefixes, federation subjects, +federation-provider route selectors, federation-provider storage keys, and replay assertion +digests are claimed in the same transaction as their owning entity. Raw email addresses, external +subjects, and secret digests never appear in unique document IDs. + +## Federation provider controls + +Each tenant provider has one `federationProviderControls/{storageKey}` document. Two deterministic +documents under `unique/` independently reserve its `(organizationId, providerId)` route and its +globally stable storage key. First acquisition, an initial disable, later compare-and-set state +changes, and the matching audit event use one Firestore transaction with create/update-time +preconditions, so a route cannot be remapped to a different protocol configuration during a race. + +Challenges for external linking persist the exact enabled-provider lease. Challenge creation and +consumption, replay receipts, external links, and federated session create/touch/rotation all read +and validate the provider control in their mutation transaction. Disabling advances the provider's +logical `sessionEpoch`; it never scans or rewrites session documents. Re-enabling retains that +epoch, so pre-disable callbacks and sessions remain invalid even though stable external links are +retained. JIT linking creates its email-less user, active viewer membership, external link, replay +receipt, and audit record in one commit; a provider disable, subject conflict, or replay conflict +therefore leaves no orphan user or membership. + +The bundled composite-index resource includes the reviewed tenant/state/provider ordering used by +operator tooling. Authority-path lease validation uses direct control and deterministic uniqueness +document reads and does not depend on a query index. Keep provider payloads unindexed and deploy the +resource before enabling federation in an environment. + +Every command uses Firestore REST `beginTransaction`, transactional reads/queries, and `commit` +with `exists` or `updateTime` preconditions. Audit records are writes in that same commit. ABORTED, +UNAVAILABLE, and DEADLINE_EXCEEDED transactions are retried from the initial read up to the +configured bound; callers receive only stable `IdentityStoreError` values. + +## Environment marker + +The database-global singleton `aetherIdentityEnvironment/current` contains the exact environment, +namespace, and marker schema version. It is outside `aetherIdentity/{namespace}`, so a second +environment or namespace cannot claim the same project/database. Normal `initialize()` only reads +and verifies it and fails closed if it is missing or mismatched. Deployment automation must invoke +the explicit, exact-match-idempotent `provisionEnvironmentMarker()` operation. A legacy +namespace-local `aetherIdentity/{namespace}/environment/current` document is ignored. Never point +development and production at the same project/database; a namespace is not an isolation boundary. +The singleton is not queried and needs no composite index; the bundled indexes apply to entity +collections only. + +## Authentication + +Production uses an injected `FirestoreAccessTokenProvider`. The supplied refreshing provider +caches short-lived OAuth tokens from a workload identity, metadata server, or service-account +credential source and refreshes before expiry. If Firestore returns HTTP 401 or the bounded Google +error status `UNAUTHENTICATED`, the transport invalidates the cached token and retries the request +exactly once. A second rejection returns only a stable generic store error; provider bodies and +bearer values are never exposed. The no-auth provider is accepted only when the API base URL is an +exact loopback Firestore emulator URL. Direct browser access is denied by the shipped rules; a +trusted Aether identity authority performs all storage calls. + +## Real-emulator release gate + +Run `./aether-auth-firestore/run-emulator-gate.sh` from the repository root with `gcloud` and its +`cloud-firestore-emulator` component installed. The gate starts a loopback emulator, uses a +dedicated test-only project and unique `TEST` namespace, provisions/verifies the environment marker, +and executes the adapter's atomicity and replay suite through the actual Firestore v1 REST API. +Absence or startup failure of the emulator fails the gate; the ordinary cross-target unit suite is +not a substitute for this release check. The shared suite includes provider first-acquire/disable +and compare-and-set races, disable/re-enable stale leases and sessions, external-link replay, and +JIT conflict/replay/disabled-provider orphan rollback. diff --git a/aether-auth-firestore/src/commonMain/resources/aether-identity/firestore.indexes.json b/aether-auth-firestore/src/commonMain/resources/aether-identity/firestore.indexes.json new file mode 100644 index 0000000..dd111cd --- /dev/null +++ b/aether-auth-firestore/src/commonMain/resources/aether-identity/firestore.indexes.json @@ -0,0 +1,192 @@ +{ + "indexes": [ + { + "collectionGroup": "credentials", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "state", "order": "ASCENDING" }, + { "fieldPath": "createdAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "sessions", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "state", "order": "ASCENDING" }, + { "fieldPath": "lastUsedAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "memberships", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "organizationId", "order": "ASCENDING" }, + { "fieldPath": "state", "order": "ASCENDING" }, + { "fieldPath": "role", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "memberships", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "organizationId", "order": "ASCENDING" }, + { "fieldPath": "state", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "federationProviderControls", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "organizationId", "order": "ASCENDING" }, + { "fieldPath": "state", "order": "ASCENDING" }, + { "fieldPath": "providerId", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "recoveryCodes", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "userId", "order": "ASCENDING" }, + { "fieldPath": "generation", "order": "DESCENDING" }, + { "fieldPath": "state", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "auditEvents", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "organizationId", "order": "ASCENDING" }, + { "fieldPath": "occurredAt", "order": "DESCENDING" }, + { "fieldPath": "entityId", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "auditEvents", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "occurredAt", "order": "ASCENDING" }, + { "fieldPath": "entityId", "order": "ASCENDING" } + ] + } + ], + "fieldOverrides": [ + { + "collectionGroup": "bootstrap", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "users", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "credentials", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "sessions", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "organizations", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "memberships", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "invitations", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "serviceIdentities", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "serviceCredentials", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "externalIdentities", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "federationProviderControls", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "challenges", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "recoveryCodes", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "deviceGrants", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "deviceTokenFamilies", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "deviceAccessTokens", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "deviceRefreshTokens", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "replayReceipts", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "auditEvents", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "scimGroups", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "scimReceipts", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "scimBatchReceipts", + "fieldPath": "payload", + "indexes": [] + }, + { + "collectionGroup": "unique", + "fieldPath": "payload", + "indexes": [] + } + ] +} diff --git a/aether-auth-firestore/src/commonMain/resources/aether-identity/firestore.rules b/aether-auth-firestore/src/commonMain/resources/aether-identity/firestore.rules new file mode 100644 index 0000000..061db55 --- /dev/null +++ b/aether-auth-firestore/src/commonMain/resources/aether-identity/firestore.rules @@ -0,0 +1,14 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + // Identity persistence is server-authoritative. Browser clients use the + // Aether identity HTTP API and never receive Firestore service credentials. + match /aetherIdentity/{document=**} { + allow read, write: if false; + } + match /aetherIdentityEnvironment/{document=**} { + allow read, write: if false; + } + } +} diff --git a/aether-auth-firestore/src/commonTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStoreTest.kt b/aether-auth-firestore/src/commonTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStoreTest.kt new file mode 100644 index 0000000..16a32a4 --- /dev/null +++ b/aether-auth-firestore/src/commonTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStoreTest.kt @@ -0,0 +1,1575 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.* +import codes.yousef.aether.auth.testkit.DeterministicIdentityClock +import codes.yousef.aether.auth.testkit.DeterministicIdentityHttpClient +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.IdentityStoreConformanceCase +import codes.yousef.aether.auth.testkit.IdentityStoreConformanceSuite +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.KSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class FirestoreIdentityStoreTest { + private val config = FirestoreIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "identity_test", + projectId = "aether-test-project", + apiBaseUrl = "http://127.0.0.1:8080/v1" + ) + + @Test + fun productionRejectsEmulatorAndCrossEnvironmentNamespace() { + assertFailsWith { + FirestoreIdentityConfig( + environment = IdentityEnvironment.PRODUCTION, + namespace = "identity_production", + projectId = "aether-prod-project", + apiBaseUrl = "http://127.0.0.1:8080/v1" + ) + } + assertFailsWith { + config.copy(environment = IdentityEnvironment.PRODUCTION) + } + } + + @Test + fun initializationFailsClosedUntilExactMarkerIsProvisioned() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = store(backend) + + assertEquals(IdentityStoreErrorCode.NOT_FOUND, assertFailure(store.initialize()).error.code) + assertIs>(store.provisionEnvironmentMarker()) + assertIs>(store.provisionEnvironmentMarker()) + val marker = backend.documentEndingWith("/aetherIdentityEnvironment/current") + assertEquals("test", marker.fields.getValue("environment").stringValue) + assertEquals("identity_test", marker.fields.getValue("namespace").stringValue) + assertEquals( + FirestoreIdentityStore.FIRESTORE_ENVIRONMENT_MARKER_SCHEMA_VERSION.toString(), + marker.fields.getValue("schemaVersion").integerValue + ) + assertIs>(store.initialize()) + + val conflictingConfig = config.copy(namespace = "secondary_test") + val conflictingStore = store(backend, conflictingConfig) + assertEquals( + IdentityStoreErrorCode.INTERNAL, + assertFailure(conflictingStore.provisionEnvironmentMarker()).error.code + ) + assertEquals( + IdentityStoreErrorCode.INTERNAL, + assertFailure(conflictingStore.initialize()).error.code + ) + + backend.corruptEnvironmentMarker(config, "development") + val mismatched = store(backend) + assertEquals(IdentityStoreErrorCode.INTERNAL, assertFailure(mismatched.initialize()).error.code) + } + + @Test + fun organizationAuditReadUsesBoundedStableKeysetAndTenantIndex() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organizationId = IdentityFixtures.organizationId("firestore-audit-org") + val otherOrganizationId = IdentityFixtures.organizationId("firestore-other-org") + suspend fun append(id: String, organization: OrganizationId, offset: Long) { + assertSuccess( + store.appendAuditEvent( + audit(id, AuditAction.ORGANIZATION_CHANGED).copy( + organizationId = organization, + occurredAt = IdentityFixtures.instant(offset) + ) + ) + ) + } + append("audit-firestore-3000", organizationId, 3_000) + append("audit-firestore-2000-b", organizationId, 2_000) + append("audit-firestore-2000-a", organizationId, 2_000) + append("audit-firestore-1000", organizationId, 1_000) + append("audit-firestore-other", otherOrganizationId, 4_000) + + val first = assertSuccess( + store.listAuditEventsForOrganization( + OrganizationAuditEventPageRequest(organizationId, limit = 2) + ) + ).value + assertEquals( + listOf( + IdentityFixtures.auditEventId("audit-firestore-3000").value, + IdentityFixtures.auditEventId("audit-firestore-2000-b").value + ), + first.events.map { it.id.value } + ) + val firstQuery = backend.queries.last() + assertEquals(3, firstQuery.limit) + assertEquals( + listOf("occurredAt", "entityId"), + firstQuery.orderBy.map { it.field.fieldPath } + ) + assertNotNull(backend.documentEndingWith("/${IdentityFixtures.auditEventId("audit-firestore-3000").value}") + .fields.getValue("occurredAt").timestampValue) + + append("audit-firestore-4000-new", organizationId, 4_000) + val second = assertSuccess( + store.listAuditEventsForOrganization( + OrganizationAuditEventPageRequest(organizationId, cursor = first.nextCursor, limit = 2) + ) + ).value + assertEquals( + listOf( + IdentityFixtures.auditEventId("audit-firestore-2000-a").value, + IdentityFixtures.auditEventId("audit-firestore-1000").value + ), + second.events.map { it.id.value } + ) + assertNull(second.nextCursor) + assertEquals(false, backend.queries.last().startAt?.before) + assertNotNull(backend.queries.last().startAt?.values?.first()?.timestampValue) + } + + @Test + fun auditRetentionUsesStrictCutoffAndPreconditionedBoundedDeletes() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organizationId = OrganizationId("018f0f2e-7b00-7000-8000-000000000010") + suspend fun append(id: String, offset: Long) { + assertSuccess( + store.appendAuditEvent( + audit(id, AuditAction.ORGANIZATION_CHANGED).copy( + organizationId = organizationId, + occurredAt = IdentityFixtures.instant(offset) + ) + ) + ) + } + append("018f0f2e-7b00-7000-8000-000000000011", 1_000) + append("018f0f2e-7b00-7000-8000-000000000012", 2_000) + append("018f0f2e-7b00-7000-8000-000000000013", 3_000) + + val first = assertSuccess( + store.purgeAuditEvents( + PurgeAuditEventsCommand(IdentityFixtures.instant(3_000), maximumEvents = 1) + ) + ).value + assertEquals(PurgeAuditEventsCommit(deletedCount = 1, hasMore = true), first) + assertEquals("LESS_THAN", backend.queries.last().where?.fieldFilter?.op) + assertEquals(2, backend.queries.last().limit) + + val second = assertSuccess( + store.purgeAuditEvents( + PurgeAuditEventsCommand(IdentityFixtures.instant(3_000), maximumEvents = 1) + ) + ).value + assertEquals(PurgeAuditEventsCommit(deletedCount = 1, hasMore = false), second) + val retained = assertSuccess( + store.listAuditEventsForOrganization(OrganizationAuditEventPageRequest(organizationId)) + ).value.events + assertEquals(listOf("018f0f2e-7b00-7000-8000-000000000013"), retained.map { it.id.value }) + } + + @Test + fun challengeCreateIsAtomicWithDeterministicUniquenessClaim() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val first = IdentityFixtures.challenge() + val duplicateDigest = IdentityFixtures.challenge(id = IdentityFixtures.challengeId("challenge-2")).copy( + challengeDigest = first.challengeDigest + ) + + assertEquals(first, assertSuccess(store.createChallenge(CreateChallengeCommand(first))).value) + assertEquals(first, assertSuccess(store.findChallenge(first.id)).value) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertFailure(store.createChallenge(CreateChallengeCommand(duplicateDigest))).error.code + ) + assertNull(assertSuccess(store.findChallenge(duplicateDigest.id)).value) + } + + @Test + fun federationProviderRouteAndStorageClaimsRejectCrossProviderRemapping() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organization = IdentityFixtures.organization( + id = IdentityFixtures.organizationId("firestore-provider-uniqueness"), + slug = "firestore-provider-uniqueness" + ) + backend.seed( + config, + "organizations", + organization.id.value, + organization, + Organization.serializer(), + mapOf("state" to "active", "slug" to organization.slug) + ) + val firstStorageKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "firestore-provider-one" + ) + val secondStorageKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "firestore-provider-two" + ) + val lease = assertSuccess( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + "workforce", + firstStorageKey, + IdentityFixtures.instant() + ) + ) + ).value + + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertFailure( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + "workforce", + secondStorageKey, + IdentityFixtures.instant(1_000) + ) + ) + ).error.code + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertFailure( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + "partners", + firstStorageKey, + IdentityFixtures.instant(1_000) + ) + ) + ).error.code + ) + assertEquals( + lease, + assertSuccess(store.validateFederationProviderLease(lease)).value + ) + assertEquals( + lease.storageKey, + assertNotNull( + assertSuccess(store.findFederationProviderControl(organization.id, "workforce")).value + ).storageKey + ) + assertNull(assertSuccess(store.findFederationProviderControl(organization.id, "partners")).value) + assertNull(assertSuccess(store.findFederationProviderControlByStorageKey(secondStorageKey)).value) + assertEquals(1, backend.countCollection(config, "federationProviderControls")) + assertEquals(2, backend.countCollection(config, "unique")) + } + + @Test + fun externalLinkChallengeLeaseRemainsStaleAcrossDisableAndReenable() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organization = IdentityFixtures.organization( + id = IdentityFixtures.organizationId("firestore-challenge-provider"), + slug = "firestore-challenge-provider" + ) + backend.seed( + config, + "organizations", + organization.id.value, + organization, + Organization.serializer(), + mapOf("state" to "active", "slug" to organization.slug) + ) + val providerId = "challenge-provider" + val storageKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "firestore-challenge-provider" + ) + val initialLease = assertSuccess( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + providerId, + storageKey, + IdentityFixtures.instant() + ) + ) + ).value + val challenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("firestore-external-link-challenge"), + purpose = ChallengePurpose.EXTERNAL_IDENTITY_LINK, + userId = null, + organizationId = organization.id, + federationProviderLease = initialLease + ) + assertSuccess(store.createChallenge(CreateChallengeCommand(challenge))) + + val enabled = assertNotNull( + assertSuccess(store.findFederationProviderControl(organization.id, providerId)).value + ) + val disabledAt = IdentityFixtures.instant(1_000) + val disableReason = "firestore_test_provider_disabled" + val disabled = assertSuccess( + store.compareAndSetFederationProviderState( + CompareAndSetFederationProviderStateCommand( + expectedVersion = enabled.version, + replacement = enabled.copy( + state = FederationProviderState.DISABLED, + sessionEpoch = enabled.sessionEpoch + 1, + version = enabled.version + 1, + updatedAt = disabledAt, + disabledAt = disabledAt, + disabledReasonCode = disableReason + ), + auditEvent = federationProviderAudit( + id = "audit-firestore-provider-disabled", + action = AuditAction.FEDERATION_PROVIDER_DISABLED, + organizationId = organization.id, + storageKey = storageKey, + occurredAt = disabledAt, + reasonCode = disableReason + ) + ) + ) + ).value.control + assertEquals( + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + assertFailure( + store.consumeChallenge( + ConsumeChallengeCommand( + challenge.id, + challenge.version, + ChallengeState.CONSUMED, + IdentityFixtures.instant(1_100), + federationProviderLease = initialLease + ) + ) + ).error.code + ) + val rejectedCreation = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("firestore-disabled-link-challenge"), + purpose = ChallengePurpose.EXTERNAL_IDENTITY_LINK, + userId = null, + organizationId = organization.id, + federationProviderLease = initialLease + ) + assertEquals( + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + assertFailure(store.createChallenge(CreateChallengeCommand(rejectedCreation))).error.code + ) + assertNull(assertSuccess(store.findChallenge(rejectedCreation.id)).value) + + val enabledAt = IdentityFixtures.instant(2_000) + assertSuccess( + store.compareAndSetFederationProviderState( + CompareAndSetFederationProviderStateCommand( + expectedVersion = disabled.version, + replacement = disabled.copy( + state = FederationProviderState.ENABLED, + version = disabled.version + 1, + updatedAt = enabledAt, + disabledAt = null, + disabledReasonCode = null + ), + auditEvent = federationProviderAudit( + id = "audit-firestore-provider-enabled", + action = AuditAction.FEDERATION_PROVIDER_ENABLED, + organizationId = organization.id, + storageKey = storageKey, + occurredAt = enabledAt + ) + ) + ) + ) + val currentLease = assertSuccess( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + providerId, + storageKey, + IdentityFixtures.instant(2_100) + ) + ) + ).value + assertEquals( + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + assertFailure( + store.consumeChallenge( + ConsumeChallengeCommand( + challenge.id, + challenge.version, + ChallengeState.CONSUMED, + IdentityFixtures.instant(2_200), + federationProviderLease = initialLease + ) + ) + ).error.code + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertFailure( + store.consumeChallenge( + ConsumeChallengeCommand( + challenge.id, + challenge.version, + ChallengeState.CONSUMED, + IdentityFixtures.instant(2_200), + federationProviderLease = currentLease + ) + ) + ).error.code + ) + assertEquals( + ChallengeState.PENDING, + assertNotNull(assertSuccess(store.findChallenge(challenge.id)).value).state + ) + } + + @Test + fun federatedSessionRotationValidatesBothReplacementAndPredecessorEpochs() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organization = IdentityFixtures.organization( + id = IdentityFixtures.organizationId("firestore-session-provider"), + slug = "firestore-session-provider" + ) + val user = IdentityFixtures.user(IdentityFixtures.userId("firestore-session-user")) + backend.seed( + config, + "organizations", + organization.id.value, + organization, + Organization.serializer(), + mapOf("state" to "active", "slug" to organization.slug) + ) + backend.seed( + config, + "users", + user.id.value, + user, + User.serializer(), + mapOf("state" to "active") + ) + val providerId = "session-provider" + val storageKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "firestore-session-provider" + ) + val initialLease = assertSuccess( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + providerId, + storageKey, + IdentityFixtures.instant() + ) + ) + ).value + val initial = IdentityFixtures.session( + id = IdentityFixtures.sessionId("firestore-federated-predecessor"), + userId = user.id, + assurance = AuthenticationAssurance.SESSION, + authenticationMethod = SessionAuthenticationMethod.OIDC, + federationOrganizationId = organization.id, + federationProviderKey = storageKey, + federationProviderSessionEpoch = initialLease.sessionEpoch, + externalIdentityId = IdentityFixtures.externalIdentityId("firestore-federated-predecessor") + ) + assertSuccess( + store.createSession( + CreateSessionCommand( + initial, + sessionAudit( + "audit-firestore-federated-session-created", + AuditAction.SESSION_CREATED, + organization.id, + initial.id, + initial.createdAt + ) + ) + ) + ) + + val replacementAt = IdentityFixtures.instant(1_000) + val wrongEpochReplacement = IdentityFixtures.session( + id = IdentityFixtures.sessionId("firestore-wrong-epoch-replacement"), + familyId = initial.familyId, + userId = user.id, + assurance = AuthenticationAssurance.SESSION, + authenticationMethod = SessionAuthenticationMethod.OIDC, + federationOrganizationId = organization.id, + federationProviderKey = storageKey, + federationProviderSessionEpoch = initialLease.sessionEpoch + 1, + externalIdentityId = initial.externalIdentityId, + rotationCounter = initial.rotationCounter + 1, + createdAt = replacementAt, + rotatedFromId = initial.id + ) + assertEquals( + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + assertFailure( + store.rotateSession( + RotateSessionCommand( + initial.id, + initial.version, + wrongEpochReplacement, + replacementAt, + sessionAudit( + "audit-firestore-wrong-replacement-epoch", + AuditAction.SESSION_ROTATED, + organization.id, + initial.id, + replacementAt + ) + ) + ) + ).error.code + ) + assertNull(assertSuccess(store.findSession(wrongEpochReplacement.id)).value) + + val current = assertNotNull( + assertSuccess(store.findFederationProviderControl(organization.id, providerId)).value + ) + val disabledAt = IdentityFixtures.instant(2_000) + val reason = "firestore_session_provider_disabled" + val disabled = assertSuccess( + store.compareAndSetFederationProviderState( + CompareAndSetFederationProviderStateCommand( + current.version, + current.copy( + state = FederationProviderState.DISABLED, + sessionEpoch = current.sessionEpoch + 1, + version = current.version + 1, + updatedAt = disabledAt, + disabledAt = disabledAt, + disabledReasonCode = reason + ), + federationProviderAudit( + "audit-firestore-session-provider-disabled", + AuditAction.FEDERATION_PROVIDER_DISABLED, + organization.id, + storageKey, + disabledAt, + reason + ) + ) + ) + ).value.control + val enabledAt = IdentityFixtures.instant(3_000) + assertSuccess( + store.compareAndSetFederationProviderState( + CompareAndSetFederationProviderStateCommand( + disabled.version, + disabled.copy( + state = FederationProviderState.ENABLED, + version = disabled.version + 1, + updatedAt = enabledAt, + disabledAt = null, + disabledReasonCode = null + ), + federationProviderAudit( + "audit-firestore-session-provider-enabled", + AuditAction.FEDERATION_PROVIDER_ENABLED, + organization.id, + storageKey, + enabledAt + ) + ) + ) + ) + val currentLease = assertSuccess( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organization.id, + FederationProviderKind.OIDC, + providerId, + storageKey, + IdentityFixtures.instant(3_100) + ) + ) + ).value + val currentReplacementAt = IdentityFixtures.instant(3_200) + val currentReplacement = IdentityFixtures.session( + id = IdentityFixtures.sessionId("firestore-current-epoch-replacement"), + familyId = initial.familyId, + userId = user.id, + assurance = AuthenticationAssurance.SESSION, + authenticationMethod = SessionAuthenticationMethod.OIDC, + federationOrganizationId = organization.id, + federationProviderKey = storageKey, + federationProviderSessionEpoch = currentLease.sessionEpoch, + externalIdentityId = initial.externalIdentityId, + rotationCounter = initial.rotationCounter + 1, + createdAt = currentReplacementAt, + rotatedFromId = initial.id + ) + assertEquals( + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + assertFailure( + store.rotateSession( + RotateSessionCommand( + initial.id, + initial.version, + currentReplacement, + currentReplacementAt, + sessionAudit( + "audit-firestore-stale-predecessor", + AuditAction.SESSION_ROTATED, + organization.id, + initial.id, + currentReplacementAt + ) + ) + ) + ).error.code + ) + assertEquals(SessionState.ACTIVE, assertNotNull(assertSuccess(store.findSession(initial.id)).value).state) + assertNull(assertSuccess(store.findSession(currentReplacement.id)).value) + } + + @Test + fun fakeFirestoreRunsSharedProviderRaceAndJitAtomicityConformance() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + + val report = IdentityStoreConformanceSuite(store, "firestore-fake").runAll() + + assertTrue(IdentityStoreConformanceCase.FEDERATION_PROVIDER_LIFECYCLE in report.cases) + assertTrue(IdentityStoreConformanceCase.FEDERATION_LINK_CONFLICTS_AND_REPLAY_RECEIPTS in report.cases) + assertTrue(IdentityStoreConformanceCase.FEDERATION_JIT_ATOMICITY in report.cases) + } + + @Test + fun providerBackendFailuresMapToStableSafeStoreErrors() { + assertEquals( + IdentityStoreError(IdentityStoreErrorCode.ALREADY_EXISTS), + FirestoreFailureMapper.fromProvider("ALREADY_EXISTS") + ) + assertEquals( + IdentityStoreError(IdentityStoreErrorCode.VERSION_CONFLICT, retryable = true), + FirestoreFailureMapper.fromProvider("FAILED_PRECONDITION") + ) + assertEquals( + IdentityStoreError(IdentityStoreErrorCode.UNAVAILABLE, retryable = true), + FirestoreFailureMapper.fromProvider("RESOURCE_EXHAUSTED") + ) + assertEquals( + IdentityStoreError(IdentityStoreErrorCode.INTERNAL), + FirestoreFailureMapper.fromProvider("PERMISSION_DENIED") + ) + assertEquals( + IdentityStoreError(IdentityStoreErrorCode.INTERNAL), + FirestoreFailureMapper.fromProvider("provider-secret-detail", httpStatus = 400) + ) + } + + @Test + fun webAuthnCredentialIdIsGloballyUniqueIndependentlyOfInternalId() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val user = IdentityFixtures.user() + backend.seed(config, "users", user.id.value, user, User.serializer(), mapOf("state" to "active")) + val challengeOne = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("registration-1"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION + ) + val challengeTwo = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("registration-2"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION + ) + assertSuccess(store.createChallenge(CreateChallengeCommand(challengeOne))) + assertSuccess(store.createChallenge(CreateChallengeCommand(challengeTwo))) + val credentialOne = IdentityFixtures.credential() + val credentialTwo = IdentityFixtures.credential( + id = IdentityFixtures.credentialId("credential-2"), + webAuthnId = credentialOne.webAuthnId + ) + + val first = CompleteCredentialRegistrationCommand( + challengeId = challengeOne.id, + expectedChallengeVersion = 0, + credential = credentialOne, + auditEvent = audit("audit-registration-1", AuditAction.CREDENTIAL_REGISTERED), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit(challengeOne.id) + ) + assertNotNull(assertSuccess(store.completeCredentialRegistration(first)).value.completion) + assertEquals(credentialOne, assertSuccess(store.findCredentialByWebAuthnId(credentialOne.webAuthnId)).value) + + val second = CompleteCredentialRegistrationCommand( + challengeId = challengeTwo.id, + expectedChallengeVersion = 0, + credential = credentialTwo, + auditEvent = audit("audit-registration-2", AuditAction.CREDENTIAL_REGISTERED), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit(challengeTwo.id) + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertSuccess(store.completeCredentialRegistration(second)).value.rejection?.error?.code + ) + assertNull(assertSuccess(store.findCredential(credentialTwo.id)).value) + assertEquals(ChallengeState.FAILED, assertNotNull(assertSuccess(store.findChallenge(challengeTwo.id)).value).state) + } + + @Test + fun transientAbortedCommitRetriesWholeTransaction() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + backend.abortNextCommit = true + val challenge = IdentityFixtures.challenge() + + assertIs>(store.createChallenge(CreateChallengeCommand(challenge))) + assertEquals(2, backend.transactionsBegun) + assertEquals(challenge, assertSuccess(store.findChallenge(challenge.id)).value) + } + + @Test + fun versionPreconditionFailureIsSafeAndRetryable() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val challenge = IdentityFixtures.challenge() + assertSuccess(store.createChallenge(CreateChallengeCommand(challenge))) + + val result = store.consumeChallenge( + ConsumeChallengeCommand( + challengeId = challenge.id, + expectedVersion = 7, + terminalState = ChallengeState.CONSUMED, + consumedAt = IdentityFixtures.instant(1_000) + ) + ) + val error = assertFailure(result).error + assertEquals(IdentityStoreErrorCode.VERSION_CONFLICT, error.code) + assertEquals(true, error.retryable) + } + + @Test + fun sessionIdleRenewalIsAtomicCasWithoutAuditAndCannotRegressOrRevive() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("firestore-idle-renewal") + ) + backend.seed( + config, + "sessions", + session.id.value, + session, + IdentitySession.serializer(), + mapOf( + "userId" to session.userId.value, + "state" to "active", + "lastUsedAt" to session.lastUsedAt.toString() + ) + ) + + val renewedAt = Instant.fromEpochMilliseconds(session.lastUsedAt.toEpochMilliseconds() + 60_000) + val renewedIdleExpiry = Instant.fromEpochMilliseconds(session.idleExpiresAt.toEpochMilliseconds() + 60_000) + val renewed = assertSuccess( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = session.version, + lastUsedAt = renewedAt, + idleExpiresAt = renewedIdleExpiry + ) + ) + ).value + assertEquals(session.version + 1, renewed.version) + assertEquals(renewedAt, renewed.lastUsedAt) + assertEquals(renewedIdleExpiry, renewed.idleExpiresAt) + assertEquals(renewed, assertSuccess(store.findSession(session.id)).value) + assertEquals(0, backend.countCollection(config, "auditEvents")) + + val shortenedIdleExpiry = Instant.fromEpochMilliseconds(renewedAt.toEpochMilliseconds() + 600_000) + val shortened = assertSuccess( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = renewed.version, + lastUsedAt = renewedAt, + idleExpiresAt = shortenedIdleExpiry + ) + ) + ).value + assertEquals(renewed.version + 1, shortened.version) + assertEquals(renewedAt, shortened.lastUsedAt) + assertEquals(shortenedIdleExpiry, shortened.idleExpiresAt) + + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertFailure( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = session.version, + lastUsedAt = renewedAt, + idleExpiresAt = renewedIdleExpiry + ) + ) + ).error.code + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertFailure( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = shortened.version, + lastUsedAt = session.lastUsedAt, + idleExpiresAt = shortenedIdleExpiry + ) + ) + ).error.code + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertFailure( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = shortened.version, + lastUsedAt = shortened.lastUsedAt, + idleExpiresAt = Instant.fromEpochMilliseconds( + shortened.absoluteExpiresAt.toEpochMilliseconds() + 1 + ) + ) + ) + ).error.code + ) + assertEquals(shortened, assertSuccess(store.findSession(session.id)).value) + + val expired = session.copy( + id = IdentityFixtures.sessionId("firestore-expired-idle-renewal"), + familyId = IdentityFixtures.sessionId("firestore-expired-idle-renewal"), + idleExpiresAt = renewedAt + ) + backend.seed( + config, + "sessions", + expired.id.value, + expired, + IdentitySession.serializer(), + mapOf( + "userId" to expired.userId.value, + "state" to "active", + "lastUsedAt" to expired.lastUsedAt.toString() + ) + ) + assertEquals( + IdentityStoreErrorCode.SESSION_EXPIRED, + assertFailure( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = expired.id, + expectedVersion = expired.version, + lastUsedAt = expired.idleExpiresAt, + idleExpiresAt = renewedIdleExpiry + ) + ) + ).error.code + ) + assertEquals(expired, assertSuccess(store.findSession(expired.id)).value) + + val revoked = IdentityFixtures.session( + id = IdentityFixtures.sessionId("firestore-revoked-idle-renewal"), + state = SessionState.REVOKED + ) + backend.seed( + config, + "sessions", + revoked.id.value, + revoked, + IdentitySession.serializer(), + mapOf( + "userId" to revoked.userId.value, + "state" to "revoked", + "lastUsedAt" to revoked.lastUsedAt.toString() + ) + ) + assertEquals( + IdentityStoreErrorCode.SESSION_NOT_ACTIVE, + assertFailure( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = revoked.id, + expectedVersion = revoked.version, + lastUsedAt = renewedAt, + idleExpiresAt = shortenedIdleExpiry + ) + ) + ).error.code + ) + assertEquals(revoked, assertSuccess(store.findSession(revoked.id)).value) + assertEquals(0, backend.countCollection(config, "auditEvents")) + } + + @Test + fun invitationEnrollmentIsSingleUseAndRollsBackEveryDependentWrite() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organization = IdentityFixtures.organization() + backend.seed( + config, + "organizations", + organization.id.value, + organization, + Organization.serializer(), + mapOf("state" to "active", "slug" to organization.slug) + ) + + val invitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("firestore-invitation-race"), + organizationId = organization.id + ).copy(email = EmailAddress("new-firestore-user@example.test")) + createInvitation(store, invitation, "audit-firestore-invitation-race-create") + val command = invitationEnrollmentCommand(invitation, "race", IdentityFixtures.instant(20_000)) + val raced = listOf( + async { store.enrollInvitation(command) }, + async { store.enrollInvitation(command) } + ).awaitAll() + val committed = assertSuccess( + raced.single { it is StoreResult.Success } + ).value + assertEquals(InvitationState.ACCEPTED, committed.invitation.state) + assertEquals(SessionAuthenticationMethod.INVITATION, committed.enrollmentSession.authenticationMethod) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertFailure(raced.single { it is StoreResult.Failure }).error.code + ) + assertEquals(committed.user, assertSuccess(store.findUser(committed.user.id)).value) + assertEquals(committed.membership, assertSuccess(store.findMembership(committed.membership.id)).value) + assertEquals(committed.enrollmentSession, assertSuccess(store.findSession(committed.enrollmentSession.id)).value) + + val wrongTokenInvitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("firestore-invitation-wrong-token"), + organizationId = organization.id + ).copy(email = EmailAddress("wrong-token-firestore@example.test")) + createInvitation(store, wrongTokenInvitation, "audit-firestore-invitation-wrong-token-create") + val wrongToken = invitationEnrollmentCommand( + wrongTokenInvitation, + "wrong-token", + IdentityFixtures.instant(21_000) + ).copy(expectedTokenDigest = wrongTokenInvitation.tokenDigest.copy(encoded = "wrong-token-digest")) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertFailure(store.enrollInvitation(wrongToken)).error.code + ) + assertInvitationEnrollmentRolledBack(store, wrongTokenInvitation, wrongToken) + + val expiredInvitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("firestore-invitation-expired"), + organizationId = organization.id + ).copy(email = EmailAddress("expired-firestore@example.test")) + createInvitation(store, expiredInvitation, "audit-firestore-invitation-expired-create") + val expired = invitationEnrollmentCommand(expiredInvitation, "expired", expiredInvitation.expiresAt) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertFailure(store.enrollInvitation(expired)).error.code + ) + assertInvitationEnrollmentRolledBack(store, expiredInvitation, expired) + + val existingUser = IdentityFixtures.user(IdentityFixtures.userId("firestore-existing-user")) + assertSuccess( + store.applyScimMutation( + ApplyScimMutationCommand( + IdentityFixtures.scimMutation( + IdentityFixtures.scimOperationId("firestore-existing-user-create"), + existingUser + ), + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-firestore-existing-user-create"), + AuditAction.SCIM_MUTATION_APPLIED, + existingUser.id.value + ) + ) + ) + ) + val duplicateEmailInvitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("firestore-invitation-duplicate-email"), + organizationId = organization.id + ).copy(email = requireNotNull(existingUser.primaryEmail)) + createInvitation(store, duplicateEmailInvitation, "audit-firestore-invitation-duplicate-email-create") + val duplicateEmail = invitationEnrollmentCommand( + duplicateEmailInvitation, + "duplicate-email", + IdentityFixtures.instant(22_000) + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertFailure(store.enrollInvitation(duplicateEmail)).error.code + ) + assertInvitationEnrollmentRolledBack(store, duplicateEmailInvitation, duplicateEmail) + } + + @Test + fun scimBatchAtomicallyCreatesUserMembershipGroupAuditsAndIdempotencyReceipt() = runTest { + val backend = FakeFirestoreDocumentTransport() + val store = initializedStore(backend) + val organization = IdentityFixtures.organization() + backend.seed( + config, + "organizations", + organization.id.value, + organization, + Organization.serializer(), + mapOf("state" to "active", "slug" to organization.slug) + ) + val user = IdentityFixtures.user(IdentityFixtures.userId("scim-batch-user")) + val membership = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("scim-batch-membership"), + userId = user.id, + role = OrganizationRole.VIEWER + ) + val provider = "test-directory:${organization.id.value}" + val userMutation = scimMutationCommand( + operationId = "scim-user-child", + provider = provider, + user = user, + membership = null, + type = ScimMutationType.UPSERT_USER, + auditId = "audit-scim-user-child" + ) + val membershipMutation = scimMutationCommand( + operationId = "scim-membership-child", + provider = provider, + user = null, + membership = membership, + type = ScimMutationType.UPSERT_MEMBERSHIP, + auditId = "audit-scim-membership-child" + ) + val group = ScimGroup( + id = "scim-batch-group", + organizationId = organization.id, + provider = provider, + displayName = "SCIM batch group", + memberUserIds = setOf(user.id), + version = 1, + createdAt = IdentityFixtures.instant(), + updatedAt = IdentityFixtures.instant(1_000) + ) + val command = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("scim-batch"), + organizationId = organization.id, + provider = provider, + mutations = listOf(userMutation, membershipMutation), + group = group, + expectedGroupVersion = 0, + auditEvent = AuditEvent( + id = IdentityFixtures.auditEventId("audit-scim-batch"), + actor = AuditActor(AuditActorType.SYSTEM), + organizationId = organization.id, + action = AuditAction.SCIM_GROUP_CHANGED, + target = AuditTarget(AuditTargetType.SCIM_GROUP, group.id), + outcome = AuditOutcome.SUCCEEDED, + occurredAt = IdentityFixtures.instant(1_000) + ) + ) + + val first = assertSuccess(store.applyScimBatch(command)).value + assertEquals(false, first.alreadyApplied) + assertEquals(user, assertSuccess(store.findUser(user.id)).value) + assertEquals(membership, assertSuccess(store.findMembership(membership.id)).value) + assertEquals(group, assertSuccess(store.findScimGroup(provider, organization.id, group.id)).value) + + val retry = assertSuccess(store.applyScimBatch(command)).value + assertTrue(retry.alreadyApplied) + assertNull(retry.auditEvent) + assertTrue(retry.mutationCommits.all { it.alreadyApplied && it.auditEvent == null }) + + val conflict = command.copy(group = group.copy(displayName = "Different payload")) + assertEquals( + IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT, + assertFailure(store.applyScimBatch(conflict)).error.code + ) + } + + @Test + fun accessTokenProviderCachesThenRefreshesBeforeExpiry() = runTest { + val clock = DeterministicIdentityClock() + var refreshCount = 0 + val provider = RefreshingFirestoreAccessTokenProvider( + clock = clock, + credentialSource = FirestoreOAuthCredentialSource { + refreshCount += 1 + FirestoreAccessToken("token-$refreshCount", clock.now() + 120.seconds) + }, + refreshSkew = 30.seconds + ) + + val first = provider.accessToken() + assertEquals(first, provider.accessToken()) + assertEquals(1, refreshCount) + clock.advanceMilliseconds(91_000) + val second = provider.accessToken() + assertEquals(2, refreshCount) + kotlin.test.assertNotEquals(first, second) + assertEquals("FirestoreAccessToken(value=, expiresAt=${second.expiresAt})", second.toString()) + } + + @Test + fun restTransportUsesV1TransactionEndpointAndBearerAuthorization() = runTest { + val http = DeterministicIdentityHttpClient( + listOf(IdentityHttpResponse(200, body = """{"transaction":"dHJhbnNhY3Rpb24"}""".encodeToByteArray())) + ) + val runtime = DeterministicIdentityRuntime(deterministicHttp = http).runtime + val transport = FirestoreRestTransport( + config = config, + runtime = runtime, + accessTokens = FirestoreAccessTokenProvider { + FirestoreAccessToken("test-oauth-token", IdentityFixtures.instant(60_000)) + } + ) + + assertEquals("dHJhbnNhY3Rpb24", transport.beginTransaction()) + val request = http.recordedRequests().single() + assertEquals(IdentityHttpMethod.POST, request.method) + assertEquals("${config.documentsRoot}:beginTransaction", request.url) + assertEquals("Bearer test-oauth-token", request.headers["Authorization"]) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals("FirestoreAccessToken(value=, expiresAt=${IdentityFixtures.instant(60_000)})", + FirestoreAccessToken("another-token", IdentityFixtures.instant(60_000)).toString()) + kotlin.test.assertContains(request.bodyBytes().decodeToString(), "readWrite") + } + + @Test + fun restTransportInvalidatesRejectedOAuthTokenAndRetriesExactlyOnce() = runTest { + val clock = DeterministicIdentityClock() + var refreshCount = 0 + val provider = RefreshingFirestoreAccessTokenProvider( + clock = clock, + credentialSource = FirestoreOAuthCredentialSource { + refreshCount += 1 + FirestoreAccessToken("rotated-token-$refreshCount", clock.now() + 120.seconds) + }, + refreshSkew = 30.seconds + ) + val http = DeterministicIdentityHttpClient( + listOf( + IdentityHttpResponse( + 403, + body = """{"error":{"status":"UNAUTHENTICATED","message":"provider detail"}}""" + .encodeToByteArray() + ), + IdentityHttpResponse(200, body = """{"transaction":"dHJhbnNhY3Rpb24"}""".encodeToByteArray()) + ) + ) + val transport = FirestoreRestTransport( + config = config, + runtime = DeterministicIdentityRuntime(deterministicHttp = http).runtime, + accessTokens = provider + ) + + assertEquals("dHJhbnNhY3Rpb24", transport.beginTransaction()) + assertEquals(2, refreshCount) + val requests = http.recordedRequests() + assertEquals(2, requests.size) + assertEquals("Bearer rotated-token-1", requests[0].headers["Authorization"]) + assertEquals("Bearer rotated-token-2", requests[1].headers["Authorization"]) + } + + @Test + fun restTransportStopsAfterOneOAuthRetryAndRedactsRejectedTokens() = runTest { + val clock = DeterministicIdentityClock() + var refreshCount = 0 + val provider = RefreshingFirestoreAccessTokenProvider( + clock = clock, + credentialSource = FirestoreOAuthCredentialSource { + refreshCount += 1 + FirestoreAccessToken("never-log-token-$refreshCount", clock.now() + 120.seconds) + }, + refreshSkew = 30.seconds + ) + val http = DeterministicIdentityHttpClient( + listOf( + IdentityHttpResponse(401, body = "rejected".encodeToByteArray()), + IdentityHttpResponse(401, body = "rejected again".encodeToByteArray()) + ) + ) + val transport = FirestoreRestTransport( + config = config, + runtime = DeterministicIdentityRuntime(deterministicHttp = http).runtime, + accessTokens = provider + ) + + val failure = assertFailsWith { transport.beginTransaction() } + assertEquals(2, refreshCount) + assertEquals(2, http.recordedRequests().size) + assertFalse(failure.toString().contains("never-log-token")) + assertFalse(failure.message.orEmpty().contains("rejected")) + } + + @Test + fun restQueryWireAlwaysIncludesRequiredEnumsAndAcceptsEmulatorTermination() { + val wire = defaultFirestoreJson().encodeToString( + RunQueryRequest( + FirestoreStructuredQuery( + from = listOf(FirestoreCollectionSelector("sessions")), + where = FirestoreFilter( + fieldFilter = FirestoreFieldFilter( + field = FirestoreFieldReference("userId"), + op = "EQUAL", + value = stringValue("user-1") + ) + ), + orderBy = listOf( + FirestoreOrder(FirestoreFieldReference("updatedAt"), "DESCENDING") + ) + ) + ) + ) + kotlin.test.assertContains(wire, "\"op\":\"EQUAL\"") + kotlin.test.assertContains(wire, "\"direction\":\"DESCENDING\"") + + val response = defaultFirestoreJson().decodeFromString>( + """[{"readTime":"2026-07-15T00:00:00Z","done":true}]""" + ) + assertEquals(true, response.single().done) + assertNull(response.single().document) + } + + private suspend fun initializedStore(backend: FakeFirestoreDocumentTransport): FirestoreIdentityStore = + store(backend).also { + assertIs>(it.provisionEnvironmentMarker()) + assertIs>(it.initialize()) + } + + private fun store( + backend: FakeFirestoreDocumentTransport, + storeConfig: FirestoreIdentityConfig = config + ): FirestoreIdentityStore = FirestoreIdentityStore( + config = storeConfig, + runtime = DeterministicIdentityRuntime().runtime, + transport = backend + ) + + private fun federationProviderAudit( + id: String, + action: AuditAction, + organizationId: OrganizationId, + storageKey: String, + occurredAt: Instant, + reasonCode: String? = null + ): AuditEvent = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId(id), + action = action, + targetId = storageKey + ).copy( + organizationId = organizationId, + target = AuditTarget(AuditTargetType.FEDERATION_PROVIDER, storageKey), + occurredAt = occurredAt, + reasonCode = reasonCode + ) + + private fun sessionAudit( + id: String, + action: AuditAction, + organizationId: OrganizationId, + sessionId: SessionId, + occurredAt: Instant + ): AuditEvent = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId(id), + action = action, + targetId = sessionId.value + ).copy( + organizationId = organizationId, + target = AuditTarget(AuditTargetType.SESSION, sessionId.value), + occurredAt = occurredAt + ) + + private fun audit(id: String, action: AuditAction): AuditEvent = IdentityFixtures.auditEvent( + id = AuditEventId.parseOrNull(id) ?: IdentityFixtures.auditEventId(id), + action = action + ) + + private suspend fun createInvitation( + store: FirestoreIdentityStore, + invitation: Invitation, + auditId: String + ) { + val event = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId(auditId), + AuditAction.INVITATION_CREATED, + invitation.id.value + ).copy( + organizationId = invitation.organizationId, + target = AuditTarget(AuditTargetType.INVITATION, invitation.id.value) + ) + assertEquals( + invitation, + assertSuccess(store.createInvitation(CreateInvitationCommand(invitation, event))).value + ) + } + + private fun invitationEnrollmentCommand( + invitation: Invitation, + suffix: String, + enrolledAt: kotlin.time.Instant + ): EnrollInvitationCommand { + val user = IdentityFixtures.user(IdentityFixtures.userId("firestore-invited-user-$suffix")).copy( + primaryEmail = invitation.email, + createdAt = enrolledAt, + updatedAt = enrolledAt, + activatedAt = enrolledAt + ) + val membership = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("firestore-invited-membership-$suffix"), + organizationId = invitation.organizationId, + userId = user.id, + role = invitation.role + ).copy(createdAt = enrolledAt, updatedAt = enrolledAt) + val expiresAt = kotlin.time.Instant.fromEpochMilliseconds(enrolledAt.toEpochMilliseconds() + 900_000) + val session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("firestore-invited-session-$suffix"), + userId = user.id, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.INVITATION, + createdAt = enrolledAt + ).copy(idleExpiresAt = expiresAt, absoluteExpiresAt = expiresAt) + val event = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-firestore-invitation-enrollment-$suffix"), + AuditAction.INVITATION_ACCEPTED, + invitation.id.value + ).copy( + organizationId = invitation.organizationId, + target = AuditTarget(AuditTargetType.INVITATION, invitation.id.value), + occurredAt = enrolledAt + ) + return EnrollInvitationCommand( + invitationId = invitation.id, + expectedInvitationVersion = invitation.version, + expectedTokenDigest = invitation.tokenDigest, + user = user, + membership = membership, + enrollmentSession = session, + enrolledAt = enrolledAt, + auditEvent = event + ) + } + + private suspend fun assertInvitationEnrollmentRolledBack( + store: FirestoreIdentityStore, + invitation: Invitation, + command: EnrollInvitationCommand + ) { + assertEquals(InvitationState.PENDING, assertSuccess(store.findInvitation(invitation.id)).value?.state) + assertNull(assertSuccess(store.findUser(command.user.id)).value) + assertNull(assertSuccess(store.findMembership(command.membership.id)).value) + assertNull(assertSuccess(store.findSession(command.enrollmentSession.id)).value) + } + + private fun scimMutationCommand( + operationId: String, + provider: String, + user: User?, + membership: Membership?, + type: ScimMutationType, + auditId: String + ): ApplyScimMutationCommand { + val target = membership?.let { AuditTarget(AuditTargetType.MEMBERSHIP, it.id.value) } + ?: AuditTarget(AuditTargetType.USER, requireNotNull(user).id.value) + return ApplyScimMutationCommand( + mutation = ScimMutation( + operationId = IdentityFixtures.scimOperationId(operationId), + provider = provider, + type = type, + externalSubject = ExternalSubject("subject-scim-batch-user"), + user = user, + membership = membership, + occurredAt = IdentityFixtures.instant(1_000) + ), + auditEvent = AuditEvent( + id = IdentityFixtures.auditEventId(auditId), + actor = AuditActor(AuditActorType.SYSTEM), + organizationId = IdentityFixtures.organizationId(), + action = AuditAction.SCIM_MUTATION_APPLIED, + target = target, + outcome = AuditOutcome.SUCCEEDED, + occurredAt = IdentityFixtures.instant(1_000) + ) + ) + } + + private fun assertSuccess(result: StoreResult): StoreResult.Success = assertIs(result) + private fun assertFailure(result: StoreResult<*>): StoreResult.Failure = assertIs(result) +} + +private class FakeFirestoreDocumentTransport : FirestoreDocumentTransport { + private val documents = mutableMapOf() + private var updateCounter = 0L + var abortNextCommit: Boolean = false + var transactionsBegun: Int = 0 + private set + val queries = mutableListOf() + + fun documentEndingWith(suffix: String): FirestoreDocument = + documents.values.single { it.name.endsWith(suffix) } + + fun countCollection(config: FirestoreIdentityConfig, collection: String): Int { + val prefix = "projects/${config.projectId}/databases/${config.databaseId}/documents/" + + "${config.namespaceDocument}/$collection/" + return documents.keys.count { name -> + name.startsWith(prefix) && '/' !in name.substring(prefix.length) + } + } + + override suspend fun get(documentName: String): FirestoreDocument? = documents[documentName] + + override suspend fun beginTransaction(): String = "transaction-${++transactionsBegun}" + + override suspend fun batchGet( + documentNames: List, + transaction: String + ): Map = documentNames.associateWith { documents[it] } + + override suspend fun runQuery( + parent: String, + query: FirestoreStructuredQuery, + transaction: String? + ): List { + queries += query + val collection = query.from.single().collectionId + val prefix = "$parent/$collection/" + val filter = query.where?.fieldFilter + val comparator = Comparator { left, right -> + for (order in query.orderBy) { + val compared = compareValues( + left.fields[order.field.fieldPath], + right.fields[order.field.fieldPath] + ) + if (compared != 0) { + return@Comparator if (order.direction == "DESCENDING") -compared else compared + } + } + left.name.compareTo(right.name) + } + var selected = documents.values.filter { document -> + document.name.startsWith(prefix) && document.name.substring(prefix.length).let { '/' !in it } && + (filter == null || when (filter.op) { + "EQUAL" -> document.fields[filter.field.fieldPath] == filter.value + "LESS_THAN" -> compareValues(document.fields[filter.field.fieldPath], filter.value) < 0 + else -> error("Unsupported fake Firestore filter ${filter.op}") + }) + }.let { values -> + if (query.orderBy.isEmpty()) values.sortedBy { it.name } else values.sortedWith(comparator) + } + query.startAt?.let { cursor -> + require(cursor.values.size == query.orderBy.size) + selected = selected.filter { document -> + val compared = compareDocumentToCursor(document, query.orderBy, cursor.values) + if (cursor.before) compared >= 0 else compared > 0 + } + } + return query.limit?.let(selected::take) ?: selected + } + + override suspend fun commit(transaction: String?, writes: List): CommitResponse { + if (abortNextCommit) { + abortNextCommit = false + throw FirestoreStoreException( + FirestoreFailureMapper.versionConflict(), + transactionRetryable = true + ) + } + writes.forEach(::validate) + writes.forEach { write -> + write.delete?.let { documents.remove(it) } + write.update?.let { update -> + documents[update.name] = update.copy(updateTime = "update-${++updateCounter}") + } + } + return CommitResponse(commitTime = "commit-${updateCounter}") + } + + override suspend fun rollback(transaction: String) = Unit + + fun seed( + config: FirestoreIdentityConfig, + collection: String, + id: String, + value: T, + serializer: KSerializer, + indexed: Map = emptyMap() + ) { + val fields = mutableMapOf( + "payload" to stringValue(defaultFirestoreJson().encodeToString(serializer, value)), + "entityId" to stringValue(id), + "environment" to stringValue(config.environment.wireName), + "namespace" to stringValue(config.namespace), + "schemaVersion" to integerValue(FirestoreIdentityStore.FIRESTORE_SCHEMA_VERSION.toLong()) + ) + indexed.forEach { (key, item) -> fields[key] = stringValue(item) } + val name = "projects/${config.projectId}/databases/${config.databaseId}/documents/" + + "${config.namespaceDocument}/$collection/$id" + documents[name] = FirestoreDocument(name, fields, updateTime = "update-${++updateCounter}") + } + + fun corruptEnvironmentMarker(config: FirestoreIdentityConfig, environment: String) { + val name = "projects/${config.projectId}/databases/${config.databaseId}/documents/" + + config.environmentMarkerDocument + val current = requireNotNull(documents[name]) + documents[name] = current.copy( + fields = current.fields + ("environment" to stringValue(environment)), + updateTime = "update-${++updateCounter}" + ) + } + + private fun validate(write: FirestoreWrite) { + val name = write.update?.name ?: requireNotNull(write.delete) + val existing = documents[name] + write.currentDocument?.exists?.let { expected -> + if ((existing != null) != expected) { + throw FirestoreStoreException(FirestoreFailureMapper.versionConflict()) + } + } + write.currentDocument?.updateTime?.let { expected -> + if (existing?.updateTime != expected) { + throw FirestoreStoreException(FirestoreFailureMapper.versionConflict()) + } + } + } + + private fun compareDocumentToCursor( + document: FirestoreDocument, + orderBy: List, + values: List + ): Int { + orderBy.forEachIndexed { index, order -> + val compared = compareValues(document.fields[order.field.fieldPath], values[index]) + if (compared != 0) return if (order.direction == "DESCENDING") -compared else compared + } + return 0 + } + + private fun compareValues(left: FirestoreValue?, right: FirestoreValue?): Int = when { + left?.timestampValue != null && right?.timestampValue != null -> + kotlin.time.Instant.parse(left.timestampValue).compareTo(kotlin.time.Instant.parse(right.timestampValue)) + else -> sortableValue(left).compareTo(sortableValue(right)) + } + + private fun sortableValue(value: FirestoreValue?): String = when { + value == null -> "" + value.stringValue != null -> value.stringValue + value.timestampValue != null -> value.timestampValue + value.integerValue != null -> value.integerValue.padStart(20, '0') + value.referenceValue != null -> value.referenceValue + else -> value.toString() + } +} diff --git a/aether-auth-firestore/src/jvmTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStoreEmulatorTest.kt b/aether-auth-firestore/src/jvmTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStoreEmulatorTest.kt new file mode 100644 index 0000000..3cb26b2 --- /dev/null +++ b/aether-auth-firestore/src/jvmTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIdentityStoreEmulatorTest.kt @@ -0,0 +1,792 @@ +package codes.yousef.aether.auth.firestore + +import codes.yousef.aether.auth.* +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.IdentityStoreConformanceCase +import codes.yousef.aether.auth.testkit.IdentityStoreConformanceSuite +import java.net.URI +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Release-gate coverage through the actual Firestore v1 REST emulator. + * + * Ordinary JVM test runs explicitly skip this class. The dedicated `firestoreEmulatorTest` task + * enables it and therefore fails closed when [FIRESTORE_EMULATOR_HOST] is absent or unreachable. + * Every run flushes a dedicated test project before and after the suite; production credentials + * and non-loopback endpoints are rejected before any request is sent. + */ +class FirestoreIdentityStoreEmulatorTest { + private val exchanges = ArrayDeque() + + @Test + @Timeout(180) + fun realRestTransactionsPreserveIdentityAtomicity() = runBlocking { + assumeTrue( + System.getProperty(EMULATOR_GATE_PROPERTY) == "true", + "Run :aether-auth-firestore:firestoreEmulatorTest to enable the real-emulator gate" + ) + val endpoint = emulatorEndpoint() + val projectId = emulatorProjectId() + val namespace = uniqueNamespace() + val runtime = jvmIdentityRuntime( + secrets = IdentitySecretResolver { IdentitySecret.fromUtf8("emulator-gate-only-secret") }, + http = RecordingIdentityHttpClient(JvmIdentityHttpClient()) + ) + val config = FirestoreIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = namespace, + projectId = projectId, + apiBaseUrl = "${endpoint.apiBaseUrl}/v1", + maximumTransactionAttempts = 10 + ) + val store = FirestoreIdentityStore(config, runtime, FirestoreEmulatorAccessTokenProvider) + + flushEmulator(runtime, endpoint, projectId) + try { + assertEquals(IdentityStoreErrorCode.NOT_FOUND, failure(store.initialize()).error.code) + success(store.provisionEnvironmentMarker()) + success(store.provisionEnvironmentMarker()) + success(store.initialize()) + val conflictingConfig = config.copy(namespace = "other_$namespace") + val conflictingStore = FirestoreIdentityStore( + conflictingConfig, + runtime, + FirestoreEmulatorAccessTokenProvider + ) + assertEquals( + IdentityStoreErrorCode.INTERNAL, + failure(conflictingStore.provisionEnvironmentMarker()).error.code + ) + assertEquals( + IdentityStoreErrorCode.INTERNAL, + failure(conflictingStore.initialize()).error.code + ) + + val baseline = bootstrap(store) + val conformance = IdentityStoreConformanceSuite(store, "firestore-real").runAll() + assertTrue(IdentityStoreConformanceCase.FEDERATION_PROVIDER_LIFECYCLE in conformance.cases) + assertTrue(IdentityStoreConformanceCase.FEDERATION_JIT_ATOMICITY in conformance.cases) + verifyConcurrentChallengeConsumption(store) + verifyCredentialUniquenessRollback(store, baseline.user) + val touchedSession = verifySessionIdleRenewal(store, baseline.enrollmentSession) + val currentUser = verifySessionRotationAndEpoch(store, baseline, touchedSession) + verifyRecoveryCodeReuse(store, currentUser) + verifyLastOwnerProtection(store, baseline.ownerMembership) + verifyInvitationEnrollment(store, baseline.organization) + verifyDeviceExchangeAndRefreshReplay(store, baseline) + verifyScimIdempotency(store, baseline.organization) + } finally { + flushEmulator(runtime, endpoint, projectId) + } + } + + private suspend fun bootstrap(store: FirestoreIdentityStore): BootstrapIdentityCommit { + val user = IdentityFixtures.user(IdentityFixtures.userId("emulator-owner")) + val organization = IdentityFixtures.organization( + id = IdentityFixtures.organizationId("emulator-organization"), + slug = "emulator-organization" + ) + val owner = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("emulator-owner-membership"), + organizationId = organization.id, + userId = user.id + ) + val session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("emulator-bootstrap-session"), + userId = user.id, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.BOOTSTRAP + ) + val command = BootstrapIdentityCommand( + bootstrapSecretDigest = SecretDigest(DigestAlgorithm.SHA256, "emulator-bootstrap-receipt"), + user = user, + organization = organization, + ownerMembership = owner, + enrollmentSession = session, + auditEvent = audit( + id = "audit-emulator-bootstrap", + action = AuditAction.IDENTITY_BOOTSTRAPPED, + targetType = AuditTargetType.USER, + targetId = user.id.value, + organizationId = organization.id + ) + ) + return success(store.bootstrapIdentity(command)).value + } + + private suspend fun verifyConcurrentChallengeConsumption(store: FirestoreIdentityStore) { + val challenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("emulator-concurrent-challenge"), + userId = IdentityFixtures.userId("emulator-owner") + ) + success(store.createChallenge(CreateChallengeCommand(challenge))) + val command = ConsumeChallengeCommand( + challengeId = challenge.id, + expectedVersion = 0, + terminalState = ChallengeState.CONSUMED, + consumedAt = IdentityFixtures.instant(1_000) + ) + val results = race { store.consumeChallenge(command) } + assertEquals(1, results.count { it is StoreResult.Success }) + assertTrue( + failure(results.single { it is StoreResult.Failure }).error.code in setOf( + IdentityStoreErrorCode.VERSION_CONFLICT, + IdentityStoreErrorCode.CHALLENGE_NOT_PENDING + ) + ) + assertEquals( + ChallengeState.CONSUMED, + assertNotNull(success(store.findChallenge(challenge.id)).value).state + ) + } + + private suspend fun verifyCredentialUniquenessRollback( + store: FirestoreIdentityStore, + user: User + ) { + val firstChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("emulator-registration-one"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION, + userId = user.id + ) + val secondChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("emulator-registration-two"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION, + userId = user.id + ) + success(store.createChallenge(CreateChallengeCommand(firstChallenge))) + success(store.createChallenge(CreateChallengeCommand(secondChallenge))) + val first = IdentityFixtures.credential( + id = IdentityFixtures.credentialId("emulator-credential-one"), + userId = user.id + ) + success( + store.completeCredentialRegistration( + CompleteCredentialRegistrationCommand( + challengeId = firstChallenge.id, + expectedChallengeVersion = 0, + credential = first, + auditEvent = audit( + "audit-emulator-credential-one", + AuditAction.CREDENTIAL_REGISTERED, + AuditTargetType.CREDENTIAL, + first.id.value + ), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit(firstChallenge.id) + ) + ) + ) + val duplicate = IdentityFixtures.credential( + id = IdentityFixtures.credentialId("emulator-credential-duplicate"), + webAuthnId = first.webAuthnId, + userId = user.id + ) + val rejected = store.completeCredentialRegistration( + CompleteCredentialRegistrationCommand( + challengeId = secondChallenge.id, + expectedChallengeVersion = 0, + credential = duplicate, + auditEvent = audit( + "audit-emulator-credential-duplicate", + AuditAction.CREDENTIAL_REGISTERED, + AuditTargetType.CREDENTIAL, + duplicate.id.value + ), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit(secondChallenge.id) + ) + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + success(rejected).value.rejection?.error?.code + ) + assertNull(success(store.findCredential(duplicate.id)).value) + assertEquals( + ChallengeState.FAILED, + assertNotNull(success(store.findChallenge(secondChallenge.id)).value).state + ) + } + + private suspend fun verifySessionRotationAndEpoch( + store: FirestoreIdentityStore, + baseline: BootstrapIdentityCommit, + currentSession: IdentitySession + ): User { + val rotatedAt = Instant.fromEpochMilliseconds(currentSession.lastUsedAt.toEpochMilliseconds() + 1_000) + val replacement = IdentityFixtures.session( + id = IdentityFixtures.sessionId("emulator-rotated-session"), + familyId = currentSession.familyId, + userId = baseline.user.id, + userSessionEpoch = baseline.user.sessionEpoch, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.BOOTSTRAP, + rotationCounter = 1, + createdAt = rotatedAt, + rotatedFromId = currentSession.id + ) + val rotation = success( + store.rotateSession( + RotateSessionCommand( + sessionId = currentSession.id, + expectedVersion = currentSession.version, + replacement = replacement, + rotatedAt = rotatedAt, + auditEvent = audit( + "audit-emulator-session-rotation", + AuditAction.SESSION_ROTATED, + AuditTargetType.SESSION, + currentSession.id.value + ) + ) + ) + ).value + assertEquals(SessionState.ROTATED, rotation.previous.state) + + val revokedAt = Instant.fromEpochMilliseconds(rotatedAt.toEpochMilliseconds() + 1_000) + val epoch = success( + store.revokeUserSessions( + RevokeUserSessionsCommand( + userId = baseline.user.id, + expectedUserVersion = baseline.user.version, + expectedSessionEpoch = baseline.user.sessionEpoch, + newSessionEpoch = baseline.user.sessionEpoch + 1, + exceptSessionId = replacement.id, + revokedAt = revokedAt, + reasonCode = "emulator_epoch_gate", + auditEvent = audit( + "audit-emulator-session-epoch", + AuditAction.SESSION_REVOKED, + AuditTargetType.USER, + baseline.user.id.value + ) + ) + ) + ).value + assertTrue(epoch.revokedSessionIds.isEmpty()) + assertEquals(1, epoch.user.sessionEpoch) + assertEquals(1, epoch.user.version) + val retained = assertNotNull(success(store.findSession(replacement.id)).value) + assertEquals(SessionState.ACTIVE, retained.state) + assertEquals(1, retained.userSessionEpoch) + return epoch.user + } + + private suspend fun verifySessionIdleRenewal( + store: FirestoreIdentityStore, + session: IdentitySession + ): IdentitySession { + val renewedAt = Instant.fromEpochMilliseconds(session.lastUsedAt.toEpochMilliseconds() + 60_000) + val renewedIdleExpiry = Instant.fromEpochMilliseconds(session.idleExpiresAt.toEpochMilliseconds() + 60_000) + val raced = race { + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = session.version, + lastUsedAt = renewedAt, + idleExpiresAt = renewedIdleExpiry + ) + ) + } + val renewed = success(raced.single { it is StoreResult.Success }).value + assertEquals(1, renewed.version) + assertEquals(renewedAt, renewed.lastUsedAt) + assertEquals(renewedIdleExpiry, renewed.idleExpiresAt) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + failure(raced.single { it is StoreResult.Failure }).error.code + ) + assertEquals(renewed, success(store.findSession(session.id)).value) + return renewed + } + + private suspend fun verifyRecoveryCodeReuse(store: FirestoreIdentityStore, user: User) { + val codes = (0 until 10).map { index -> + IdentityFixtures.recoveryCode( + id = IdentityFixtures.recoveryCodeId("emulator-recovery-$index"), + userId = user.id, + generation = 0 + ) + } + success( + store.replaceRecoveryCodes( + ReplaceRecoveryCodesCommand( + userId = user.id, + expectedGeneration = null, + newGeneration = 0, + codes = codes, + auditEvent = audit( + "audit-emulator-recovery-generation", + AuditAction.RECOVERY_CODES_REPLACED, + AuditTargetType.USER, + user.id.value + ) + ) + ) + ) + val consumedAt = IdentityFixtures.instant(5_000) + val recoverySession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("emulator-recovery-session"), + userId = user.id, + userSessionEpoch = user.sessionEpoch, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.RECOVERY_CODE, + createdAt = consumedAt + ) + val command = ConsumeRecoveryCodeCommand( + recoveryCodeId = codes.first().id, + expectedVersion = 0, + consumedAt = consumedAt, + recoverySession = recoverySession, + auditEvent = audit( + "audit-emulator-recovery-consumed", + AuditAction.RECOVERY_CODE_USED, + AuditTargetType.USER, + codes.first().id.value + ) + ) + val results = race { store.consumeRecoveryCode(command) } + assertEquals(1, results.count { it is StoreResult.Success }) + assertEquals( + IdentityStoreErrorCode.RECOVERY_CODE_NOT_ACTIVE, + failure(results.single { it is StoreResult.Failure }).error.code + ) + assertEquals( + RecoveryCodeState.CONSUMED, + assertNotNull(success(store.findRecoveryCodeBySelector(codes.first().publicSelector)).value).state + ) + } + + private suspend fun verifyLastOwnerProtection( + store: FirestoreIdentityStore, + owner: Membership + ) { + val replacement = owner.copy( + role = OrganizationRole.ADMIN, + version = owner.version + 1, + updatedAt = IdentityFixtures.instant(6_000) + ) + val result = store.mutateMembership( + MutateMembershipCommand( + membershipId = owner.id, + expectedVersion = owner.version, + replacement = replacement, + auditEvent = audit( + "audit-emulator-last-owner", + AuditAction.MEMBERSHIP_CHANGED, + AuditTargetType.MEMBERSHIP, + owner.id.value, + owner.organizationId + ) + ) + ) + assertEquals(IdentityStoreErrorCode.LAST_OWNER, failure(result).error.code) + assertEquals(OrganizationRole.OWNER, assertNotNull(success(store.findMembership(owner.id)).value).role) + } + + private suspend fun verifyInvitationEnrollment( + store: FirestoreIdentityStore, + organization: Organization + ) { + val invitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("emulator-invitation"), + organizationId = organization.id + ).copy(email = EmailAddress("emulator-invitee@example.test")) + success( + store.createInvitation( + CreateInvitationCommand( + invitation, + audit( + "audit-emulator-invitation-created", + AuditAction.INVITATION_CREATED, + AuditTargetType.INVITATION, + invitation.id.value, + organization.id + ) + ) + ) + ) + val enrolledAt = IdentityFixtures.instant(7_000) + val user = IdentityFixtures.user(IdentityFixtures.userId("emulator-invited-user")).copy( + primaryEmail = invitation.email, + createdAt = enrolledAt, + updatedAt = enrolledAt, + activatedAt = enrolledAt + ) + val membership = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("emulator-invited-membership"), + organizationId = organization.id, + userId = user.id, + role = invitation.role + ).copy(createdAt = enrolledAt, updatedAt = enrolledAt) + val enrollmentExpiresAt = enrolledAt + 15.minutes + val session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("emulator-invited-session"), + userId = user.id, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.INVITATION, + createdAt = enrolledAt + ).copy(idleExpiresAt = enrollmentExpiresAt, absoluteExpiresAt = enrollmentExpiresAt) + val command = EnrollInvitationCommand( + invitationId = invitation.id, + expectedInvitationVersion = invitation.version, + expectedTokenDigest = invitation.tokenDigest, + user = user, + membership = membership, + enrollmentSession = session, + enrolledAt = enrolledAt, + auditEvent = audit( + "audit-emulator-invitation-enrolled", + AuditAction.INVITATION_ACCEPTED, + AuditTargetType.INVITATION, + invitation.id.value, + organization.id, + occurredAt = enrolledAt + ) + ) + val results = race { store.enrollInvitation(command) } + val committed = success(results.single { it is StoreResult.Success }).value + assertEquals(InvitationState.ACCEPTED, committed.invitation.state) + assertEquals(SessionAuthenticationMethod.INVITATION, committed.enrollmentSession.authenticationMethod) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + failure(results.single { it is StoreResult.Failure }).error.code + ) + } + + private suspend fun verifyDeviceExchangeAndRefreshReplay( + store: FirestoreIdentityStore, + baseline: BootstrapIdentityCommit + ) { + val pending = IdentityFixtures.deviceGrant( + id = IdentityFixtures.deviceGrantId("emulator-device-grant"), + state = DeviceGrantState.PENDING + ) + success( + store.compareAndSetDeviceGrant( + CompareAndSetDeviceGrantCommand( + expectedVersion = null, + replacement = pending, + auditEvent = audit( + "audit-emulator-device-pending", + AuditAction.DEVICE_GRANT_CHANGED, + AuditTargetType.DEVICE_GRANT, + pending.id.value, + baseline.organization.id + ) + ) + ) + ) + val authorizedAt = IdentityFixtures.instant(8_000) + val authorized = pending.copy( + approvedCapabilities = pending.requestedCapabilities, + state = DeviceGrantState.AUTHORIZED, + userId = baseline.user.id, + organizationId = baseline.organization.id, + membershipId = baseline.ownerMembership.id, + membershipVersion = baseline.ownerMembership.version, + authorizedByUserId = baseline.user.id, + version = 1, + authorizedAt = authorizedAt + ) + success( + store.compareAndSetDeviceGrant( + CompareAndSetDeviceGrantCommand( + expectedVersion = 0, + replacement = authorized, + auditEvent = audit( + "audit-emulator-device-authorized", + AuditAction.DEVICE_GRANT_CHANGED, + AuditTargetType.DEVICE_GRANT, + pending.id.value, + baseline.organization.id + ) + ) + ) + ) + + val exchangedAt = IdentityFixtures.instant(9_000) + val family = DeviceTokenFamily( + id = IdentityFixtures.deviceTokenFamilyId("emulator-device-family"), + deviceGrantId = pending.id, + clientId = pending.clientId, + userId = baseline.user.id, + organizationId = baseline.organization.id, + membershipId = baseline.ownerMembership.id, + membershipVersion = baseline.ownerMembership.version, + capabilities = authorized.approvedCapabilities, + createdAt = exchangedAt, + expiresAt = IdentityFixtures.instant(500_000) + ) + val access = accessToken("emulator-access-one", family.id, exchangedAt, 100_000) + val refresh = refreshToken("emulator-refresh-one", family.id, exchangedAt, 0) + val exchange = ExchangeDeviceGrantCommand( + deviceGrantId = pending.id, + expectedDeviceGrantVersion = authorized.version, + family = family, + accessToken = access, + refreshToken = refresh, + exchangedAt = exchangedAt, + auditEvent = audit( + "audit-emulator-device-exchange", + AuditAction.DEVICE_TOKEN_ISSUED, + AuditTargetType.DEVICE_GRANT, + pending.id.value, + baseline.organization.id + ) + ) + success(store.exchangeDeviceGrant(exchange)) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + failure(store.exchangeDeviceGrant(exchange)).error.code + ) + + val rotatedAt = IdentityFixtures.instant(10_000) + val replacementAccess = accessToken("emulator-access-two", family.id, rotatedAt, 110_000) + val replacementRefresh = refreshToken("emulator-refresh-two", family.id, rotatedAt, 1) + val rotate = RotateDeviceRefreshTokenCommand( + refreshTokenId = refresh.id, + expectedRefreshTokenVersion = refresh.version, + expectedFamilyVersion = family.version, + replacementAccessToken = replacementAccess, + replacementRefreshToken = replacementRefresh, + rotatedAt = rotatedAt, + auditEvent = audit( + "audit-emulator-device-refresh", + AuditAction.DEVICE_TOKEN_REFRESHED, + AuditTargetType.DEVICE_GRANT, + family.deviceGrantId.value, + baseline.organization.id + ) + ) + success(store.rotateDeviceRefreshToken(rotate)) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + failure(store.rotateDeviceRefreshToken(rotate)).error.code + ) + + val replayDetectedAt = IdentityFixtures.instant(11_000) + success( + store.revokeDeviceTokenFamily( + RevokeDeviceTokenFamilyCommand( + familyId = family.id, + expectedFamilyVersion = family.version, + revokedAt = replayDetectedAt, + reasonCode = "refresh_replay_detected", + replayDetected = true, + auditEvent = audit( + "audit-emulator-device-replay", + AuditAction.DEVICE_TOKEN_REPLAY_DETECTED, + AuditTargetType.DEVICE_GRANT, + family.id.value, + baseline.organization.id + ) + ) + ) + ) + assertEquals( + DeviceTokenFamilyState.REVOKED, + assertNotNull(success(store.findDeviceTokenFamily(family.id)).value).state + ) + assertEquals( + DeviceAccessTokenState.REVOKED, + assertNotNull(success(store.findDeviceAccessTokenBySelector(replacementAccess.publicSelector)).value).state + ) + assertEquals( + DeviceRefreshTokenState.REVOKED, + assertNotNull(success(store.findDeviceRefreshTokenBySelector(replacementRefresh.publicSelector)).value).state + ) + } + + private suspend fun verifyScimIdempotency( + store: FirestoreIdentityStore, + organization: Organization + ) { + val provider = "emulator-scim:${organization.id.value}" + val user = IdentityFixtures.user(IdentityFixtures.userId("emulator-scim-user")) + val mutation = ScimMutation( + operationId = IdentityFixtures.scimOperationId("emulator-scim-child"), + provider = provider, + type = ScimMutationType.UPSERT_USER, + externalSubject = ExternalSubject("emulator-scim-subject"), + user = user, + occurredAt = IdentityFixtures.instant(12_000) + ) + val child = ApplyScimMutationCommand( + mutation, + audit( + "audit-emulator-scim-child", + AuditAction.SCIM_MUTATION_APPLIED, + AuditTargetType.USER, + user.id.value, + organization.id, + occurredAt = mutation.occurredAt + ) + ) + val command = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("emulator-scim-batch"), + organizationId = organization.id, + provider = provider, + mutations = listOf(child), + auditEvent = audit( + "audit-emulator-scim-batch", + AuditAction.SCIM_MUTATION_APPLIED, + AuditTargetType.USER, + user.id.value, + organization.id, + occurredAt = mutation.occurredAt + ) + ) + assertEquals(false, success(store.applyScimBatch(command)).value.alreadyApplied) + assertEquals(true, success(store.applyScimBatch(command)).value.alreadyApplied) + assertEquals( + IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT, + failure( + store.applyScimBatch( + command.copy( + mutations = listOf( + child.copy(mutation = mutation.copy(user = user.copy(displayName = "Changed payload"))) + ) + ) + ) + ).error.code + ) + } + + private fun accessToken( + suffix: String, + familyId: DeviceTokenFamilyId, + createdAt: Instant, + expiryOffset: Long + ): DeviceAccessToken = DeviceAccessToken( + id = IdentityFixtures.deviceAccessTokenId(suffix), + familyId = familyId, + publicSelector = "selector_$suffix", + secretDigest = IdentityFixtures.digest("secret-$suffix"), + createdAt = createdAt, + expiresAt = IdentityFixtures.instant(expiryOffset) + ) + + private fun refreshToken( + suffix: String, + familyId: DeviceTokenFamilyId, + createdAt: Instant, + rotationCounter: Long + ): DeviceRefreshToken = DeviceRefreshToken( + id = IdentityFixtures.deviceRefreshTokenId(suffix), + familyId = familyId, + publicSelector = "selector_$suffix", + secretDigest = IdentityFixtures.digest("secret-$suffix"), + rotationCounter = rotationCounter, + createdAt = createdAt, + expiresAt = IdentityFixtures.instant(400_000) + ) + + private fun audit( + id: String, + action: AuditAction, + targetType: AuditTargetType, + targetId: String, + organizationId: OrganizationId? = null, + occurredAt: Instant = IdentityFixtures.instant() + ): AuditEvent = AuditEvent( + id = IdentityFixtures.auditEventId(id), + actor = AuditActor(AuditActorType.SYSTEM), + organizationId = organizationId, + action = action, + target = AuditTarget(targetType, targetId), + outcome = AuditOutcome.SUCCEEDED, + occurredAt = occurredAt + ) + + private suspend fun race(block: suspend () -> StoreResult): List> = + coroutineScope { + listOf( + async(Dispatchers.IO) { block() }, + async(Dispatchers.IO) { block() } + ).awaitAll() + } + + private fun emulatorEndpoint(): EmulatorEndpoint { + val raw = requireNotNull(System.getenv("FIRESTORE_EMULATOR_HOST")) { + "FIRESTORE_EMULATOR_HOST is required by the Firestore emulator release gate" + } + require(raw == raw.trim() && "://" !in raw && '/' !in raw && '@' !in raw) { + "FIRESTORE_EMULATOR_HOST must be a bare loopback host:port" + } + val uri = URI.create("http://$raw") + require(uri.port in 1..65_535 && uri.host in LOOPBACK_HOSTS) { + "The Firestore emulator release gate only permits an exact loopback host" + } + return EmulatorEndpoint("http://$raw") + } + + private fun emulatorProjectId(): String { + val projectId = System.getenv("AETHER_FIRESTORE_EMULATOR_PROJECT_ID") + ?: "aether-identity-emulator-gate" + require(projectId.startsWith("aether-identity-emulator-") && "prod" !in projectId) { + "The emulator gate requires a dedicated non-production Aether test project ID" + } + return projectId + } + + private fun uniqueNamespace(): String { + val nonce = (System.getenv("GITHUB_RUN_ID") ?: ProcessHandle.current().pid().toString()) + .lowercase() + .filter(Char::isLetterOrDigit) + .take(24) + return "identity_test_emulator_$nonce" + } + + private suspend fun flushEmulator( + runtime: IdentityRuntime, + endpoint: EmulatorEndpoint, + projectId: String + ) { + val response = runtime.http.execute( + IdentityHttpRequest( + method = IdentityHttpMethod.DELETE, + url = "${endpoint.apiBaseUrl}/emulator/v1/projects/$projectId/databases/(default)/documents" + ) + ) + check(response.statusCode in 200..299) { + "Firestore emulator reset failed with HTTP ${response.statusCode}" + } + } + + private fun success(result: StoreResult): StoreResult.Success = + assertIs(result, "Expected store success but received $result. Recent REST exchanges: $exchanges") + private fun failure(result: StoreResult<*>): StoreResult.Failure = assertIs(result) + + private data class EmulatorEndpoint(val apiBaseUrl: String) + + private inner class RecordingIdentityHttpClient( + private val delegate: IdentityHttpClient + ) : IdentityHttpClient { + override suspend fun execute(request: IdentityHttpRequest): IdentityHttpResponse { + val response = delegate.execute(request) + if (exchanges.size == 24) exchanges.removeFirst() + exchanges.addLast( + "${request.method} ${request.url.substringAfter("/v1/")} -> ${response.statusCode}" + ) + return response + } + } + + private companion object { + const val EMULATOR_GATE_PROPERTY = "aether.firestore.emulator.gate" + val LOOPBACK_HOSTS = setOf("localhost", "127.0.0.1", "0:0:0:0:0:0:0:1", "::1") + } +} diff --git a/aether-auth-firestore/src/jvmTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIndexResourceTest.kt b/aether-auth-firestore/src/jvmTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIndexResourceTest.kt new file mode 100644 index 0000000..45ef3c4 --- /dev/null +++ b/aether-auth-firestore/src/jvmTest/kotlin/codes/yousef/aether/auth/firestore/FirestoreIndexResourceTest.kt @@ -0,0 +1,62 @@ +package codes.yousef.aether.auth.firestore + +import java.lang.reflect.Modifier +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class FirestoreIndexResourceTest { + @Test + fun everyPayloadCollectionDisablesPayloadIndexing() { + val resource = requireNotNull( + FirestoreIndexResourceTest::class.java.getResourceAsStream( + "/aether-identity/firestore.indexes.json" + ) + ) { "Missing shipped Firestore index resource" } + val root = resource.bufferedReader().use { reader -> + Json.parseToJsonElement(reader.readText()).jsonObject + } + val payloadOverrides = root.getValue("fieldOverrides").jsonArray + .map { it.jsonObject } + .filter { it.getValue("fieldPath").jsonPrimitive.content == "payload" } + + val overriddenCollections = payloadOverrides + .map { it.getValue("collectionGroup").jsonPrimitive.content } + assertEquals( + overriddenCollections.toSet().size, + overriddenCollections.size, + "Payload index exemptions must not be duplicated" + ) + payloadOverrides.forEach { override -> + assertTrue( + override.getValue("indexes").jsonArray.isEmpty(), + "The payload field must have all indexes disabled for " + + override.getValue("collectionGroup").jsonPrimitive.content + ) + } + + assertEquals( + payloadCollectionsDeclaredByStore(), + overriddenCollections.toSortedSet(), + "Every encodeDocument-backed identity collection must disable payload indexing" + ) + } + + private fun payloadCollectionsDeclaredByStore(): Set = + FirestoreIdentityStore::class.java.declaredFields + .asSequence() + .filter { field -> + field.name.startsWith("COLLECTION_") && + field.type == String::class.java && + Modifier.isStatic(field.modifiers) + } + .map { field -> + field.isAccessible = true + field.get(null) as String + } + .toSortedSet() +} diff --git a/aether-auth-oidc/build.gradle.kts b/aether-auth-oidc/build.gradle.kts new file mode 100644 index 0000000..ff3ad03 --- /dev/null +++ b/aether-auth-oidc/build.gradle.kts @@ -0,0 +1,24 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm { compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } + wasmJs { nodejs() } + wasmWasi { nodejs() } + sourceSets { + commonMain.dependencies { + api(project(":aether-auth")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(project(":aether-auth-testkit")) + } + } +} diff --git a/aether-auth-oidc/gradle.lockfile b/aether-auth-oidc/gradle.lockfile new file mode 100644 index 0000000..d014f7e --- /dev/null +++ b/aether-auth-oidc/gradle.lockfile @@ -0,0 +1,93 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.fasterxml.jackson.core:jackson-core:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.netty:netty-buffer:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +junit:junit:4.13.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-wasi:2.3.21=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit:2.3.21=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-wasi:2.3.21=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.26.1=wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.30.0-beta=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-wasi:1.10.2=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-wasi:1.10.2=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-wasi:0.7.1=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,wasmWasiMainImplementationDependenciesMetadata,wasmWasiTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/BoundedJson.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/BoundedJson.kt new file mode 100644 index 0000000..046e71d --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/BoundedJson.kt @@ -0,0 +1,268 @@ +package codes.yousef.aether.auth.oidc + +/** Small strict JSON reader used for untrusted discovery, JWKS, token, and JWT documents. */ +internal object BoundedJson { + fun parseObject( + bytes: ByteArray, + maximumBytes: Int, + maximumDepth: Int = 12, + maximumEntries: Int = 1_024, + maximumStringCharacters: Int = 16_384 + ): JsonObjectValue { + if (bytes.size > maximumBytes) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val text = try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: Exception) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + return Parser(text, maximumDepth, maximumEntries, maximumStringCharacters).parseRootObject() + } + + fun parseJwtObject(bytes: ByteArray, maximumBytes: Int): JsonObjectValue { + if (bytes.size > maximumBytes) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val text = try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: Exception) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + return try { + Parser(text, maximumDepth = 10, maximumEntries = 512, maximumStringCharacters = 8_192) + .parseRootObject() + } catch (failure: OidcAbort) { + if (failure.code == OidcErrorCode.PROVIDER_METADATA_INVALID) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + throw failure + } + } +} + +internal sealed interface JsonValue +internal data class JsonObjectValue(val members: Map) : JsonValue +internal data class JsonArrayValue(val elements: List) : JsonValue +internal data class JsonStringValue(val value: String) : JsonValue +internal data class JsonNumberValue(val source: String) : JsonValue +internal data class JsonBooleanValue(val value: Boolean) : JsonValue +internal data object JsonNullValue : JsonValue + +internal fun JsonObjectValue.requiredString(name: String, maximumLength: Int = 8_192): String { + val value = (members[name] as? JsonStringValue)?.value + ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + if (value.isEmpty() || value.length > maximumLength) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + return value +} + +internal fun JsonObjectValue.optionalString(name: String, maximumLength: Int = 8_192): String? { + val raw = members[name] ?: return null + if (raw === JsonNullValue) return null + val value = (raw as? JsonStringValue)?.value ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + if (value.length > maximumLength) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + return value +} + +internal fun JsonObjectValue.optionalStringSet( + name: String, + maximumElements: Int = 128, + maximumElementLength: Int = 512 +): Set? { + val raw = members[name] ?: return null + if (raw === JsonNullValue) return null + val array = raw as? JsonArrayValue ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + if (array.elements.size > maximumElements) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val result = LinkedHashSet() + array.elements.forEach { element -> + val value = (element as? JsonStringValue)?.value ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + if (value.isEmpty() || value.length > maximumElementLength || !result.add(value)) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + } + return result +} + +internal fun JsonObjectValue.requiredLong(name: String, errorCode: OidcErrorCode): Long { + val source = (members[name] as? JsonNumberValue)?.source ?: oidcAbort(errorCode) + if ('.' in source || 'e' in source || 'E' in source) oidcAbort(errorCode) + return source.toLongOrNull() ?: oidcAbort(errorCode) +} + +private class Parser( + private val source: String, + private val maximumDepth: Int, + private val maximumEntries: Int, + private val maximumStringCharacters: Int +) { + private var index = 0 + private var entries = 0 + + fun parseRootObject(): JsonObjectValue { + skipWhitespace() + val result = parseValue(0) as? JsonObjectValue ?: fail() + skipWhitespace() + if (index != source.length) fail() + return result + } + + private fun parseValue(depth: Int): JsonValue { + if (depth > maximumDepth || index >= source.length) fail() + return when (source[index]) { + '{' -> parseObject(depth + 1) + '[' -> parseArray(depth + 1) + '"' -> JsonStringValue(parseString()) + 't' -> { consumeLiteral("true"); JsonBooleanValue(true) } + 'f' -> { consumeLiteral("false"); JsonBooleanValue(false) } + 'n' -> { consumeLiteral("null"); JsonNullValue } + '-', in '0'..'9' -> JsonNumberValue(parseNumber()) + else -> fail() + } + } + + private fun parseObject(depth: Int): JsonObjectValue { + index++ + skipWhitespace() + val values = LinkedHashMap() + if (consumeIf('}')) return JsonObjectValue(values) + while (true) { + if (index >= source.length || source[index] != '"') fail() + val key = parseString() + skipWhitespace() + expect(':') + skipWhitespace() + countEntry() + if (key in values) fail() + values[key] = parseValue(depth) + skipWhitespace() + if (consumeIf('}')) return JsonObjectValue(values) + expect(',') + skipWhitespace() + } + } + + private fun parseArray(depth: Int): JsonArrayValue { + index++ + skipWhitespace() + val values = ArrayList() + if (consumeIf(']')) return JsonArrayValue(values) + while (true) { + countEntry() + values += parseValue(depth) + skipWhitespace() + if (consumeIf(']')) return JsonArrayValue(values) + expect(',') + skipWhitespace() + } + } + + private fun parseString(): String { + expect('"') + val result = StringBuilder() + while (index < source.length) { + val character = source[index++] + when { + character == '"' -> return result.toString() + character == '\\' -> appendEscape(result) + character.code < 0x20 -> fail() + character.isHighSurrogate() -> { + if (index >= source.length || !source[index].isLowSurrogate()) fail() + result.append(character).append(source[index++]) + } + character.isLowSurrogate() -> fail() + else -> result.append(character) + } + if (result.length > maximumStringCharacters) fail() + } + fail() + } + + private fun appendEscape(result: StringBuilder) { + if (index >= source.length) fail() + when (val escaped = source[index++]) { + '"', '\\', '/' -> result.append(escaped) + 'b' -> result.append('\b') + 'f' -> result.append('\u000c') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'u' -> { + val first = readHexCodeUnit() + when { + first in 0xD800..0xDBFF -> { + if (index + 2 > source.length || source[index] != '\\' || source[index + 1] != 'u') fail() + index += 2 + val second = readHexCodeUnit() + if (second !in 0xDC00..0xDFFF) fail() + result.append(first.toChar()).append(second.toChar()) + } + first in 0xDC00..0xDFFF -> fail() + else -> result.append(first.toChar()) + } + } + else -> fail() + } + } + + private fun readHexCodeUnit(): Int { + if (index + 4 > source.length) fail() + var value = 0 + repeat(4) { + val digit = source[index++].digitToIntOrNull(16) ?: fail() + value = (value shl 4) or digit + } + return value + } + + private fun parseNumber(): String { + val start = index + consumeIf('-') + if (consumeIf('0')) { + if (index < source.length && source[index].isDigit()) fail() + } else { + if (index >= source.length || source[index] !in '1'..'9') fail() + while (index < source.length && source[index].isDigit()) index++ + } + if (consumeIf('.')) { + if (index >= source.length || !source[index].isDigit()) fail() + while (index < source.length && source[index].isDigit()) index++ + } + if (index < source.length && (source[index] == 'e' || source[index] == 'E')) { + index++ + if (index < source.length && (source[index] == '+' || source[index] == '-')) index++ + if (index >= source.length || !source[index].isDigit()) fail() + while (index < source.length && source[index].isDigit()) index++ + } + if (index - start > 64) fail() + return source.substring(start, index) + } + + private fun consumeLiteral(literal: String) { + if (!source.startsWith(literal, index)) fail() + index += literal.length + } + + private fun skipWhitespace() { + while (index < source.length && + (source[index] == ' ' || source[index] == '\t' || source[index] == '\r' || source[index] == '\n') + ) index++ + } + + private fun expect(character: Char) { + if (!consumeIf(character)) fail() + } + + private fun consumeIf(character: Char): Boolean { + if (index < source.length && source[index] == character) { + index++ + return true + } + return false + } + + private fun countEntry() { + entries++ + if (entries > maximumEntries) fail() + } + + private fun fail(): Nothing = oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) +} + +internal class OidcAbort(val code: OidcErrorCode) : RuntimeException() +internal fun oidcAbort(code: OidcErrorCode): Nothing = throw OidcAbort(code) diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcCodec.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcCodec.kt new file mode 100644 index 0000000..e081d76 --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcCodec.kt @@ -0,0 +1,186 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.IdentityCrypto + +internal fun Char.isProtocolControl(): Boolean = code < 0x20 || code in 0x7f..0x9f + +internal fun formEncode(parameters: List>): ByteArray = + parameters.joinToString("&") { (name, value) -> "${percentEncode(name)}=${percentEncode(value)}" }.encodeToByteArray() + +internal fun appendQuery(url: String, parameters: List>): String { + val separator = if ('?' in url) '&' else '?' + return buildString(url.length + parameters.sumOf { it.first.length + it.second.length + 2 }) { + append(url) + append(separator) + parameters.forEachIndexed { index, (name, value) -> + if (index > 0) append('&') + append(percentEncode(name)).append('=').append(percentEncode(value)) + } + } +} + +internal fun percentEncode(value: String): String = percentEncode(value.encodeToByteArray()) + +internal fun percentEncode(bytes: ByteArray): String = buildString(bytes.size * 3) { + val hex = "0123456789ABCDEF" + bytes.forEach { byte -> + val value = byte.toInt() and 0xff + if ((value in 'A'.code..'Z'.code) || (value in 'a'.code..'z'.code) || + (value in '0'.code..'9'.code) || value == '-'.code || value == '.'.code || + value == '_'.code || value == '~'.code + ) { + append(value.toChar()) + } else { + append('%').append(hex[value ushr 4]).append(hex[value and 0x0f]) + } + } +} + +internal fun queryParameterNames(url: String): Set { + if ('?' !in url) return emptySet() + val query = url.substringAfter('?') + if (query.isEmpty()) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val result = LinkedHashSet() + query.split('&').forEach { parameter -> + if (parameter.isEmpty()) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val encodedName = parameter.substringBefore('=') + val name = percentDecode(encodedName) + if (name.isEmpty() || !result.add(name)) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + return result +} + +private fun percentDecode(value: String): String { + val output = ByteArray(value.length) + var inputIndex = 0 + var outputIndex = 0 + while (inputIndex < value.length) { + when (val character = value[inputIndex++]) { + '%' -> { + if (inputIndex + 2 > value.length) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val high = value[inputIndex++].digitToIntOrNull(16) + ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val low = value[inputIndex++].digitToIntOrNull(16) + ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + output[outputIndex++] = ((high shl 4) or low).toByte() + } + '+' -> output[outputIndex++] = ' '.code.toByte() + else -> { + if (character.code > 0x7f) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + output[outputIndex++] = character.code.toByte() + } + } + } + return try { + output.copyOf(outputIndex).decodeToString(throwOnInvalidSequence = true) + } catch (_: Exception) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } finally { + output.fill(0) + } +} + +internal fun standardBase64(bytes: ByteArray): String { + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + if (bytes.isEmpty()) return "" + val output = StringBuilder((bytes.size + 2) / 3 * 4) + var index = 0 + while (index < bytes.size) { + val first = bytes[index++].toInt() and 0xff + val second = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1 + val third = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1 + output.append(alphabet[first ushr 2]) + output.append(alphabet[((first and 3) shl 4) or if (second >= 0) second ushr 4 else 0]) + if (second < 0) { + output.append("==") + } else { + output.append(alphabet[((second and 15) shl 2) or if (third >= 0) third ushr 6 else 0]) + output.append(if (third < 0) '=' else alphabet[third and 63]) + } + } + return output.toString() +} + +internal suspend fun providerStorageKey(config: OidcProviderConfig, crypto: IdentityCrypto): String { + val canonical = lengthPrefixed( + config.tenantId.value.encodeToByteArray(), + config.providerId.encodeToByteArray(), + config.issuer.encodeToByteArray() + ) + return try { + val digest = crypto.sha256(canonical) + try { + if (digest.size != 32) oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + "oidc.${Base64Url.encode(digest)}" + } finally { + digest.fill(0) + } + } finally { + canonical.fill(0) + } +} + +internal fun lengthPrefixed(vararg values: ByteArray): ByteArray { + val size = values.sumOf { 4 + it.size } + val output = ByteArray(size) + var offset = 0 + values.forEach { value -> + val length = value.size + output[offset++] = (length ushr 24).toByte() + output[offset++] = (length ushr 16).toByte() + output[offset++] = (length ushr 8).toByte() + output[offset++] = length.toByte() + value.copyInto(output, offset) + offset += length + } + return output +} + +internal fun rsaSubjectPublicKeyInfo(modulus: ByteArray, exponent: ByteArray): ByteArray { + if (modulus.size !in 256..1_024 || modulus[0] == 0.toByte()) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val significantBits = modulus.size * 8 - modulus[0].countLeadingZeroBits() + if (significantBits < 2_048) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + if (exponent.isEmpty() || exponent.size > 4 || exponent[0] == 0.toByte()) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + var exponentValue = 0L + exponent.forEach { exponentValue = (exponentValue shl 8) or (it.toInt() and 0xff).toLong() } + if (exponentValue < 3 || exponentValue > 0xffff_ffffL || exponentValue and 1L == 0L) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + + val rsaKey = derSequence(derInteger(modulus), derInteger(exponent)) + val algorithm = derSequence( + byteArrayOf(0x06, 0x09, 0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x01, 0x01), + byteArrayOf(0x05, 0x00) + ) + return derSequence(algorithm, derValue(0x03, byteArrayOf(0) + rsaKey)) +} + +private fun derInteger(unsigned: ByteArray): ByteArray { + var offset = 0 + while (offset < unsigned.lastIndex && unsigned[offset] == 0.toByte()) offset++ + val value = unsigned.copyOfRange(offset, unsigned.size) + val positive = if (value[0].toInt() and 0x80 != 0) byteArrayOf(0) + value else value + return derValue(0x02, positive) +} + +private fun derSequence(vararg children: ByteArray): ByteArray = derValue(0x30, children.fold(ByteArray(0), ByteArray::plus)) + +private fun derValue(tag: Int, value: ByteArray): ByteArray = byteArrayOf(tag.toByte()) + derLength(value.size) + value + +private fun derLength(length: Int): ByteArray = when { + length < 0x80 -> byteArrayOf(length.toByte()) + length <= 0xff -> byteArrayOf(0x81.toByte(), length.toByte()) + length <= 0xffff -> byteArrayOf(0x82.toByte(), (length ushr 8).toByte(), length.toByte()) + else -> byteArrayOf( + 0x84.toByte(), + (length ushr 24).toByte(), + (length ushr 16).toByte(), + (length ushr 8).toByte(), + length.toByte() + ) +} diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcDiscovery.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcDiscovery.kt new file mode 100644 index 0000000..b43cad6 --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcDiscovery.kt @@ -0,0 +1,250 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.P256PublicKey +import codes.yousef.aether.auth.RsaPublicKey +import kotlin.time.Instant +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal data class OidcMetadata( + val issuer: String, + val authorizationEndpoint: String, + val tokenEndpoint: String, + val jwksUri: String, + val idTokenSigningAlgorithms: Set, + val tokenEndpointAuthenticationMethods: Set? +) + +internal sealed interface OidcVerificationKey { + val keyId: String + val algorithm: String + + data class Es256( + override val keyId: String, + val publicKey: P256PublicKey + ) : OidcVerificationKey { + override val algorithm: String = "ES256" + } + + data class Rs256( + override val keyId: String, + val publicKey: RsaPublicKey, + val signatureSize: Int + ) : OidcVerificationKey { + override val algorithm: String = "RS256" + } +} + +internal class OidcProviderDocuments( + private val config: OidcProviderConfig, + private val runtime: IdentityRuntime +) { + private val lock = Mutex() + private var metadataCache: Timed? = null + private var jwksCache: Timed>? = null + + suspend fun metadata(forceRefresh: Boolean = false): OidcMetadata = lock.withLock { + val now = runtime.clock.now() + val existing = metadataCache + if (!forceRefresh && existing != null && existing.expiresAt > now) return@withLock existing.value + val loaded = loadMetadata() + if (existing?.value?.jwksUri != loaded.jwksUri) jwksCache = null + metadataCache = Timed(loaded, now + config.discoveryCacheLifetime) + loaded + } + + suspend fun verificationKey(keyId: String, algorithm: String, forceRefresh: Boolean = false): OidcVerificationKey? = + lock.withLock { + val now = runtime.clock.now() + val metadata = metadataCache?.takeIf { it.expiresAt > now }?.value ?: loadMetadata().also { + metadataCache = Timed(it, now + config.discoveryCacheLifetime) + } + val existing = jwksCache + val keys = if (!forceRefresh && existing != null && existing.expiresAt > now) { + existing.value + } else { + loadJwks(metadata.jwksUri).also { + jwksCache = Timed(it, now + config.jwksCacheLifetime) + } + } + keys.singleOrNull { it.keyId == keyId && it.algorithm == algorithm } + } + + private suspend fun loadMetadata(): OidcMetadata { + val response = executeGet("${config.issuer}/.well-known/openid-configuration", config.maximumDiscoveryBytes) + val body = response + val document = try { + BoundedJson.parseObject(body, config.maximumDiscoveryBytes) + } catch (failure: OidcAbort) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } finally { + body.fill(0) + } + val issuer = document.requiredString("issuer", 2_048) + if (issuer != config.issuer) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val authorizationEndpoint = document.requiredString("authorization_endpoint", 4_096) + val tokenEndpoint = document.requiredString("token_endpoint", 4_096) + val jwksUri = document.requiredString("jwks_uri", 4_096) + validateProviderUrl(authorizationEndpoint) + validateProviderUrl(tokenEndpoint) + validateProviderUrl(jwksUri) + if (queryParameterNames(authorizationEndpoint).any { it in AUTHORIZATION_REQUEST_PARAMETERS } || + queryParameterNames(tokenEndpoint).any { it in TOKEN_REQUEST_PARAMETERS } + ) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + + val responseTypes = document.optionalStringSet("response_types_supported") + if (responseTypes == null || "code" !in responseTypes) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val subjectTypes = document.optionalStringSet("subject_types_supported") + if (subjectTypes == null || subjectTypes.none { it == "public" || it == "pairwise" }) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val grantTypes = document.optionalStringSet("grant_types_supported") + if (grantTypes != null && "authorization_code" !in grantTypes) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val supportedScopes = document.optionalStringSet("scopes_supported") + if (supportedScopes != null && "openid" !in supportedScopes) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val pkceMethods = document.optionalStringSet("code_challenge_methods_supported") + if (pkceMethods == null || "S256" !in pkceMethods) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + val signingAlgorithms = document.optionalStringSet("id_token_signing_alg_values_supported") + if (signingAlgorithms == null || signingAlgorithms.none { it == "ES256" || it == "RS256" }) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val authMethods = document.optionalStringSet("token_endpoint_auth_methods_supported") + if (config.clientSecretReference == null && (authMethods == null || "none" !in authMethods)) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + if (config.clientSecretReference != null && authMethods != null && "client_secret_basic" !in authMethods) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + return OidcMetadata( + issuer, + authorizationEndpoint, + tokenEndpoint, + jwksUri, + signingAlgorithms.filter { it == "ES256" || it == "RS256" }.toSet(), + authMethods + ) + } + + private suspend fun loadJwks(url: String): List { + val body = executeGet(url, config.maximumJwksBytes) + val document = try { + BoundedJson.parseObject(body, config.maximumJwksBytes) + } catch (failure: OidcAbort) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } finally { + body.fill(0) + } + val keyArray = document.members["keys"] as? JsonArrayValue + ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + if (keyArray.elements.isEmpty() || keyArray.elements.size > 128) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val parsed = keyArray.elements.mapNotNull { raw -> + val key = raw as? JsonObjectValue ?: oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + parseVerificationKey(key) + } + val identifiers = HashSet>() + if (parsed.any { !identifiers.add(it.keyId to it.algorithm) }) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + if (parsed.isEmpty()) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + return parsed + } + + private fun parseVerificationKey(key: JsonObjectValue): OidcVerificationKey? { + val use = key.optionalString("use", 32) + if (use != null && use != "sig") return null + val operations = key.optionalStringSet("key_ops", maximumElements = 16, maximumElementLength = 32) + if (operations != null && "verify" !in operations) return null + val keyId = key.optionalString("kid", 255) ?: return null + if (keyId.isEmpty() || keyId.any { it.isWhitespace() || it.isProtocolControl() }) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + val algorithm = key.optionalString("alg", 16) + return when (key.requiredString("kty", 16)) { + "EC" -> { + if (algorithm != null && algorithm != "ES256") return null + if (key.requiredString("crv", 32) != "P-256") return null + val x = decodeJwkValue(key.requiredString("x", 128), 32) + val y = decodeJwkValue(key.requiredString("y", 128), 32) + if (x.size != 32 || y.size != 32) oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + OidcVerificationKey.Es256(keyId, P256PublicKey(byteArrayOf(0x04) + x + y)) + } + "RSA" -> { + if (algorithm != null && algorithm != "RS256") return null + val modulus = decodeJwkValue(key.requiredString("n", 2_048), 1_024) + val exponent = decodeJwkValue(key.requiredString("e", 16), 4) + val spki = rsaSubjectPublicKeyInfo(modulus, exponent) + OidcVerificationKey.Rs256(keyId, RsaPublicKey(spki), modulus.size) + } + else -> null + } + } + + private fun decodeJwkValue(value: String, maximumBytes: Int): ByteArray = try { + Base64Url.decode(value, maximumBytes) + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + + private suspend fun executeGet(url: String, maximumBytes: Int): ByteArray { + val response = try { + runtime.http.execute( + IdentityHttpRequest( + method = IdentityHttpMethod.GET, + url = url, + headers = mapOf("Accept" to "application/json"), + maximumResponseBytes = maximumBytes + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + oidcAbort(OidcErrorCode.DISCOVERY_UNAVAILABLE) + } + if (response.statusCode !in 200..299) oidcAbort(OidcErrorCode.DISCOVERY_UNAVAILABLE) + val bytes = response.bodyBytes() + if (bytes.size > maximumBytes) { + bytes.fill(0) + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + return bytes + } + + private fun validateProviderUrl(url: String) { + if (url.length > 4_096 || '#' in url || url.any { it.isWhitespace() || it.isProtocolControl() }) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + try { + IdentityHttpRequest(IdentityHttpMethod.GET, url) + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + if (oidcEndpointOrigin(url) !in config.allowedEndpointOrigins) { + oidcAbort(OidcErrorCode.PROVIDER_METADATA_INVALID) + } + } + + private data class Timed(val value: T, val expiresAt: Instant) + + private companion object { + val AUTHORIZATION_REQUEST_PARAMETERS = setOf( + "response_type", "client_id", "redirect_uri", "scope", "state", "nonce", + "code_challenge", "code_challenge_method" + ) + val TOKEN_REQUEST_PARAMETERS = setOf( + "grant_type", "code", "redirect_uri", "client_id", "code_verifier" + ) + } +} diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcFederationHttpMiddleware.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcFederationHttpMiddleware.kt new file mode 100644 index 0000000..a218752 --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcFederationHttpMiddleware.kt @@ -0,0 +1,823 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.AuditRequestMetadata +import codes.yousef.aether.auth.IdentityAuditRedactor +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.DeviceMetadata +import codes.yousef.aether.auth.FederationCallbackStateConsumeResult +import codes.yousef.aether.auth.FederationCallbackStateStore +import codes.yousef.aether.auth.FederationCallbackStateWriteResult +import codes.yousef.aether.auth.FederatedIdentitySessionCreator +import codes.yousef.aether.auth.FederatedIdentitySessionRequest +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.IdentityConfig +import codes.yousef.aether.auth.IdentityErrorCode +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.IdentityOperationResult +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.SameSitePolicy +import codes.yousef.aether.auth.SessionId +import codes.yousef.aether.auth.identityContext +import codes.yousef.aether.core.Cookie +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.pipeline.Middleware +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val OIDC_COOKIE_NAME = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}") +private val OIDC_HEADER_NAME = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}") + +/** One tenant/provider entry resolved from application-owned configuration. */ +class OidcFederationProviderRegistration( + val provider: OidcFederationProvider, + allowedAuthorizationEndpoints: Set, + val successRedirectUrl: String +) { + val allowedAuthorizationEndpoints: Set = allowedAuthorizationEndpoints.toSet() + + init { + require(this.allowedAuthorizationEndpoints.isNotEmpty() && + this.allowedAuthorizationEndpoints.size <= 8 + ) { "At least one bounded OIDC authorization endpoint is required" } + this.allowedAuthorizationEndpoints.forEach(::requireRedirectEndpoint) + requireSafeRedirect(successRedirectUrl) + } + + override fun toString(): String = + "OidcFederationProviderRegistration(tenant=${provider.configuredTenantId}, " + + "provider=${provider.configuredProviderId}, authorizationEndpoints=, " + + "successRedirect=)" +} + +sealed interface OidcFederationProviderResolution { + data class Found(val registration: OidcFederationProviderRegistration) : OidcFederationProviderResolution + /** The exact route belongs to another installed federation adapter (for example SAML). */ + data object NotOwned : OidcFederationProviderResolution + data object Missing : OidcFederationProviderResolution + data object Unavailable : OidcFederationProviderResolution +} + +/** Registry lookup is deliberately exact and tenant scoped. */ +fun interface OidcFederationProviderRegistry { + suspend fun resolve(tenantId: OrganizationId, providerId: String): OidcFederationProviderResolution +} + +/** + * Secret-bearing callback correlation retained only by an injected server-side store. It is not + * serializable, and its diagnostic representation never includes the PKCE verifier or binding. + */ +class OidcServerCallbackState internal constructor( + val providerLease: FederationProviderLease, + internal val callbackSecret: OidcCallbackSecret, + callbackBinding: ByteArray, + val predecessorSessionId: SessionId?, + val expectedPredecessorVersion: Long?, + val expiresAt: Instant +) { + private val callbackBindingValue = callbackBinding.copyOf() + val tenantId: OrganizationId get() = providerLease.organizationId + val providerId: String get() = providerLease.providerId + + init { + require(callbackBindingValue.size == CALLBACK_BINDING_BYTES) + require(providerLease.kind == FederationProviderKind.OIDC) { + "OIDC callback state requires an OIDC provider lease" + } + require((predecessorSessionId == null) == (expectedPredecessorVersion == null)) { + "OIDC predecessor selector and version must either both be present or both be absent" + } + require(expectedPredecessorVersion == null || expectedPredecessorVersion >= 0) { + "OIDC predecessor version must not be negative" + } + } + + internal fun callbackBinding(): ByteArray = callbackBindingValue.copyOf() + + /** + * Supplies defensive copies to an application-owned authenticated-encryption boundary for a + * distributed server-side state store. The block must not return or log either byte array. + */ + suspend fun useForProtection( + block: suspend ( + providerLease: FederationProviderLease, + challengeId: codes.yousef.aether.auth.ChallengeId, + verifierSeed: ByteArray, + callbackBinding: ByteArray, + predecessorSessionId: SessionId?, + expectedPredecessorVersion: Long?, + expiresAt: Instant + ) -> T + ): T { + val binding = callbackBindingValue.copyOf() + return try { + callbackSecret.useSeedForProtection { verifier -> + block( + providerLease, + callbackSecret.challengeId, + verifier, + binding, + predecessorSessionId, + expectedPredecessorVersion, + expiresAt + ) + } + } finally { + binding.fill(0) + } + } + + /** Zero callback material when a store evicts or expires this record without consuming it. */ + fun destroy() { + callbackBindingValue.fill(0) + callbackSecret.destroy() + } + + override fun toString(): String = + "OidcServerCallbackState(tenantId=$tenantId, providerId=$providerId, " + + "callbackSecret=, callbackBinding=, " + + "predecessor=${if (predecessorSessionId == null) "none" else "present"}, expiresAt=$expiresAt)" + + companion object { + /** Restore only after application-owned authenticated decryption of server-side state. */ + fun restore( + providerLease: FederationProviderLease, + challengeId: codes.yousef.aether.auth.ChallengeId, + verifierSeed: ByteArray, + callbackBinding: ByteArray, + expiresAt: Instant, + predecessorSessionId: SessionId? = null, + expectedPredecessorVersion: Long? = null + ): OidcServerCallbackState = OidcServerCallbackState( + providerLease, + OidcCallbackSecret.restore(challengeId, verifierSeed), + callbackBinding, + predecessorSessionId, + expectedPredecessorVersion, + expiresAt + ) + } +} + +data class OidcFederationHttpConfig( + val stateCookieName: String = "__Host-aether_oidc_state", + val csrfCookieName: String = "__Host-aether_csrf", + val requestIdHeader: String = "X-Request-ID", + val maximumQueryBytes: Int = 12_288, + val csrfCookieLifetimeSeconds: Long = 300 +) { + init { + require(OIDC_COOKIE_NAME.matches(stateCookieName) && stateCookieName.startsWith("__Host-")) { + "OIDC state cookie must be a valid __Host- cookie" + } + require(OIDC_COOKIE_NAME.matches(csrfCookieName) && csrfCookieName.startsWith("__Host-")) { + "OIDC CSRF handoff cookie must be a valid __Host- cookie" + } + require(OIDC_HEADER_NAME.matches(requestIdHeader)) { "Invalid request-ID header" } + require(maximumQueryBytes in 1_024..65_536) { "OIDC query limit must be 1 KiB..64 KiB" } + require(csrfCookieLifetimeSeconds in 30..600) { "OIDC CSRF handoff lifetime must be 30..600 seconds" } + } +} + +@Serializable +enum class OidcFederationHttpErrorCode { + @SerialName("request_invalid") REQUEST_INVALID, + @SerialName("provider_not_found") PROVIDER_NOT_FOUND, + @SerialName("identity_response_invalid") IDENTITY_RESPONSE_INVALID, + @SerialName("service_unavailable") SERVICE_UNAVAILABLE +} + +@Serializable +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +data class OidcFederationHttpError( + val code: OidcFederationHttpErrorCode, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val message: String = code.genericMessage, + val requestId: String, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val retryable: Boolean = code.defaultRetryable +) { + init { + require(message == code.genericMessage) { "OIDC federation errors must use the stable generic message" } + require(retryable == code.defaultRetryable) { "OIDC federation retryability is fixed by error code" } + } +} + +internal val OidcFederationHttpErrorCode.genericMessage: String + get() = when (this) { + OidcFederationHttpErrorCode.REQUEST_INVALID -> "The federation request is invalid." + OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND -> "The identity provider was not found." + OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID -> "The identity response could not be accepted." + OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE -> "The identity service is temporarily unavailable." + } + +internal val OidcFederationHttpErrorCode.defaultRetryable: Boolean + get() = this == OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE + +/** + * Common-code OIDC transport for the two fixed federation endpoints: + * `/identity/v1/federation/{tenantId}/{providerId}/start` and `/callback`. + * + * Provider configuration, callback-state persistence, and session persistence are mandatory + * dependencies. No PKCE verifier, assertion, session secret, or provider exception is written to + * JSON or a redirect location. + */ +class OidcFederationHttpMiddleware( + private val runtime: IdentityRuntime, + private val identityConfig: IdentityConfig, + private val providers: OidcFederationProviderRegistry, + private val callbackStates: FederationCallbackStateStore, + private val sessions: FederatedIdentitySessionCreator, + private val config: OidcFederationHttpConfig = OidcFederationHttpConfig() +) { + private val auditRedactor = IdentityAuditRedactor(runtime, identityConfig) + fun asMiddleware(): Middleware = middleware@{ exchange, next -> + if (!isFederationPath(exchange.request.path)) { + next() + return@middleware + } + + val requestId = requestId(exchange) + secureResponse(exchange) + if (hasInvalidRequestId(exchange)) { + fail(exchange, 400, OidcFederationHttpErrorCode.REQUEST_INVALID, requestId) + return@middleware + } + try { + val route = parseRoute(exchange.request.path) + if (route == null) { + fail(exchange, 404, OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return@middleware + } + val resolution = try { + providers.resolve(route.tenantId, route.providerId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + OidcFederationProviderResolution.Unavailable + } + val registration = when (resolution) { + is OidcFederationProviderResolution.Found -> resolution.registration + OidcFederationProviderResolution.NotOwned -> { + next() + return@middleware + } + OidcFederationProviderResolution.Missing -> { + fail(exchange, 404, OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return@middleware + } + OidcFederationProviderResolution.Unavailable -> { + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return@middleware + } + } + if ( + registration.provider.configuredTenantId != route.tenantId || + registration.provider.configuredProviderId != route.providerId + ) { + fail(exchange, 404, OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return@middleware + } + + when (route.action) { + FederationAction.START -> handleStart(exchange, registration, route, requestId) + FederationAction.CALLBACK -> handleCallback(exchange, registration, route, requestId) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + } + } + + private suspend fun handleStart( + exchange: Exchange, + registration: OidcFederationProviderRegistration, + route: FederationRoute, + requestId: String + ) { + if (exchange.request.method != HttpMethod.GET) { + methodNotAllowed(exchange, requestId, "GET") + return + } + if (!exchange.request.query.isNullOrEmpty() || !hasNoBody(exchange)) { + fail(exchange, 400, OidcFederationHttpErrorCode.REQUEST_INVALID, requestId) + return + } + + previousStateSelector(exchange)?.let { oldSelector -> + try { + val stale = callbackStates.consume(oldSelector) + if (stale is FederationCallbackStateConsumeResult.Consumed) stale.state.destroy() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + // A stale browser cookie must not make a fresh start depend on best-effort cleanup. + } + } + + val binding = runtime.secureRandom.nextBytes(CALLBACK_BINDING_BYTES) + if (binding.size != CALLBACK_BINDING_BYTES) { + binding.fill(0) + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + val started = try { + registration.provider.beginAuthorization(OidcAuthorizationRequest(binding)) + } catch (cancelled: CancellationException) { + binding.fill(0) + throw cancelled + } catch (_: Throwable) { + binding.fill(0) + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + val value = when (started) { + is OidcResult.Success -> started.value + is OidcResult.Failure -> { + binding.fill(0) + val disabled = started.error.code == OidcErrorCode.PROVIDER_DISABLED + fail( + exchange, + if (disabled) 404 else if (started.error.retryable) 503 else 400, + if (disabled) OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND + else if (started.error.retryable) OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE + else OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, + requestId + ) + return + } + } + if (!registration.allowsAuthorizationRedirect(value.authorizationUrl)) { + binding.fill(0) + value.callbackSecret.destroy() + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + if (value.providerLease.organizationId != route.tenantId || + value.providerLease.providerId != route.providerId || + value.providerLease.kind != FederationProviderKind.OIDC + ) { + binding.fill(0) + value.callbackSecret.destroy() + fail(exchange, 404, OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return + } + + val predecessor = exchange.identityContext.session + val state = OidcServerCallbackState( + providerLease = value.providerLease, + callbackSecret = value.callbackSecret, + callbackBinding = binding, + predecessorSessionId = predecessor?.id, + expectedPredecessorVersion = predecessor?.version, + expiresAt = value.expiresAt + ) + binding.fill(0) + val selector = storeState(state) + if (selector == null) { + state.destroy() + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + val lifetime = (value.expiresAt.toEpochMilliseconds() - runtime.clock.now().toEpochMilliseconds()) / 1_000 + if (lifetime <= 0) { + callbackStates.consume(selector) + state.destroy() + fail(exchange, 400, OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + exchange.response.setCookie(stateCookie(selector, lifetime)) + redirect(exchange, value.authorizationUrl, 302) + } + + private suspend fun handleCallback( + exchange: Exchange, + registration: OidcFederationProviderRegistration, + route: FederationRoute, + requestId: String + ) { + exchange.response.setCookie(clearStateCookie()) + if (exchange.request.method != HttpMethod.GET) { + methodNotAllowed(exchange, requestId, "GET") + return + } + if (!hasNoBody(exchange)) { + fail(exchange, 400, OidcFederationHttpErrorCode.REQUEST_INVALID, requestId) + return + } + val query = parseCallbackQuery(exchange.request.query) + val selector = stateSelector(exchange) + if (query == null || selector == null) { + fail(exchange, 400, OidcFederationHttpErrorCode.REQUEST_INVALID, requestId) + return + } + val stored = try { + callbackStates.consume(selector) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + FederationCallbackStateConsumeResult.Unavailable + } + val state = when (stored) { + is FederationCallbackStateConsumeResult.Consumed -> stored.state + FederationCallbackStateConsumeResult.Missing -> { + fail(exchange, 400, OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + FederationCallbackStateConsumeResult.Unavailable -> { + fail(exchange, 503, OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + } + try { + if (state.tenantId != route.tenantId || state.providerId != route.providerId || + state.expiresAt <= runtime.clock.now() + ) { + fail(exchange, 400, OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + + val binding = state.callbackBinding() + val audit = auditRequest(exchange, requestId) + val completed = try { + registration.provider.completeAuthorization( + OidcCallbackRequest( + state = query.getValue("state"), + authorizationCode = query.getValue("code"), + callbackBinding = binding, + callbackSecret = state.callbackSecret, + providerLease = state.providerLease, + auditRequest = audit + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + OidcResult.Failure(OidcError(OidcErrorCode.STORE_UNAVAILABLE)) + } finally { + binding.fill(0) + } + val authenticated = when (completed) { + is OidcResult.Success -> completed.value + is OidcResult.Failure -> { + val disabled = completed.error.code == OidcErrorCode.PROVIDER_DISABLED + fail( + exchange, + if (disabled) 404 else if (completed.error.retryable) 503 else 400, + if (disabled) OidcFederationHttpErrorCode.PROVIDER_NOT_FOUND + else if (completed.error.retryable) OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE + else OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, + requestId + ) + return + } + } + if (authenticated.providerLease != state.providerLease || + authenticated.providerLease.organizationId != route.tenantId || + authenticated.providerLease.providerId != route.providerId || + authenticated.providerLease.kind != FederationProviderKind.OIDC || + authenticated.authenticationMethod != codes.yousef.aether.auth.SessionAuthenticationMethod.OIDC || + authenticated.assurance != codes.yousef.aether.auth.AuthenticationAssurance.SESSION || + !authenticated.passkeyStepUpRequiredForSensitiveActions + ) { + fail(exchange, 400, OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + val currentSession = exchange.identityContext.session + if (state.predecessorSessionId != null && currentSession != null && + state.predecessorSessionId != currentSession.id + ) { + fail(exchange, 400, OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + val predecessorSessionId = state.predecessorSessionId ?: currentSession?.id + val expectedPredecessorVersion = if (currentSession != null && currentSession.id == predecessorSessionId) { + currentSession.version + } else { + state.expectedPredecessorVersion + } + val session = sessions.create( + FederatedIdentitySessionRequest( + userId = authenticated.userId, + providerLease = authenticated.providerLease, + externalIdentityId = authenticated.externalIdentityId, + authenticationMethod = authenticated.authenticationMethod, + authenticatedAt = runtime.clock.now(), + device = DeviceMetadata(userAgent = singleSafeHeader(exchange, "User-Agent")?.take(2_048)), + predecessorSessionId = predecessorSessionId, + expectedPredecessorVersion = expectedPredecessorVersion, + auditRequest = audit + ) + ) + val issued = when (session) { + is IdentityOperationResult.Success -> session.value + is IdentityOperationResult.Failure -> { + val unavailable = session.code == IdentityErrorCode.SERVICE_UNAVAILABLE || session.code.retryable + fail( + exchange, + if (unavailable) 503 else 400, + if (unavailable) OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE + else OidcFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, + requestId + ) + return + } + } + exchange.response.setCookie(identitySessionCookie(issued.cookieValue())) + // This short-lived, script-readable value is session-bound and never authenticates a + // request by itself. Keeping it out of Location avoids history and referrer disclosure. + exchange.response.setCookie(csrfHandoffCookie(issued.csrfToken())) + redirect(exchange, registration.successRedirectUrl, 303) + } finally { + state.destroy() + } + } + + private suspend fun storeState(state: OidcServerCallbackState): String? { + repeat(MAX_SELECTOR_ATTEMPTS) { + val entropy = runtime.secureRandom.nextBytes(STATE_SELECTOR_BYTES) + if (entropy.size != STATE_SELECTOR_BYTES) { + entropy.fill(0) + return null + } + val selector = try { + Base64Url.encode(entropy) + } finally { + entropy.fill(0) + } + when (try { + callbackStates.store(selector, state) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + FederationCallbackStateWriteResult.Unavailable + }) { + FederationCallbackStateWriteResult.Stored -> return selector + FederationCallbackStateWriteResult.Conflict -> Unit + FederationCallbackStateWriteResult.Unavailable -> return null + } + } + return null + } + + private suspend fun hasNoBody(exchange: Exchange): Boolean { + if (exchange.request.headers.getAll("Transfer-Encoding").isNotEmpty()) return false + val lengths = exchange.request.headers.getAll("Content-Length") + if (lengths.size > 1 || lengths.singleOrNull()?.let { it != "0" } == true) return false + if (exchange.request.headers.getAll("Content-Type").isNotEmpty()) return false + val bytes = exchange.request.bodyBytes() + return try { + bytes.isEmpty() + } finally { + bytes.fill(0) + } + } + + private fun parseCallbackQuery(raw: String?): Map? { + if (raw.isNullOrEmpty() || raw.length > config.maximumQueryBytes) return null + return try { + val result = linkedMapOf() + val fields = raw.split('&') + if (fields.size != 2) return null + fields.forEach { field -> + val separator = field.indexOf('=') + if (separator <= 0) return null + val name = decodeFormComponent(field.substring(0, separator)) + val value = decodeFormComponent(field.substring(separator + 1)) + if (name !in CALLBACK_QUERY_FIELDS || value.any(::isProtocolControl) || + result.put(name, value) != null + ) return null + } + val state = result["state"] ?: return null + val code = result["code"] ?: return null + if (state.length !in 16..512 || state.any(Char::isWhitespace) || + code.length !in 1..8_192 || code.any(Char::isWhitespace) + ) null else result + } catch (_: IllegalArgumentException) { + null + } + } + + private fun stateSelector(exchange: Exchange): String? { + if (exchange.request.headers.getAll("Cookie").size > 1) return null + val fromParsed = exchange.request.cookies[config.stateCookieName]?.value + val raw = exchange.request.headers["Cookie"] ?: return fromParsed?.takeIf(STATE_SELECTOR::matches) + if (raw.length > MAXIMUM_COOKIE_HEADER || raw.any(::isProtocolControl)) return null + val parts = raw.split(';') + if (parts.size > MAXIMUM_COOKIE_FIELDS) return null + val matches = parts.map(String::trim).mapNotNull { item -> + val separator = item.indexOf('=') + if (separator <= 0 || item.substring(0, separator) != config.stateCookieName) null + else item.substring(separator + 1) + } + if (matches.size != 1 || (fromParsed != null && fromParsed != matches.single())) return null + return matches.single().takeIf(STATE_SELECTOR::matches) + } + + private fun previousStateSelector(exchange: Exchange): String? = stateSelector(exchange) + + private fun requestId(exchange: Exchange): String { + val supplied = exchange.request.headers.getAll(config.requestIdHeader) + supplied.singleOrNull()?.takeIf(REQUEST_ID::matches)?.let { return it } + val bytes = runtime.secureRandom.nextBytes(REQUEST_ID_BYTES) + return try { + if (bytes.size == REQUEST_ID_BYTES) "req_${Base64Url.encode(bytes)}" else "req_unavailable" + } finally { + bytes.fill(0) + } + } + + private fun hasInvalidRequestId(exchange: Exchange): Boolean { + val values = exchange.request.headers.getAll(config.requestIdHeader) + return values.size > 1 || values.singleOrNull()?.let { !REQUEST_ID.matches(it) } == true + } + + private suspend fun auditRequest(exchange: Exchange, requestId: String): AuditRequestMetadata = + AuditRequestMetadata( + requestId = requestId, + method = exchange.request.method.name, + path = exchange.request.path.take(4_096), + userAgent = auditRedactor.userAgent(singleSafeHeader(exchange, "User-Agent")) + ) + + private fun singleSafeHeader(exchange: Exchange, name: String): String? { + val values = exchange.request.headers.getAll(name) + if (values.size > 1) return null + return values.singleOrNull()?.takeIf { value -> + value.length <= 8_192 && value.none(::isProtocolControl) + } + } + + private suspend fun methodNotAllowed(exchange: Exchange, requestId: String, allow: String) { + exchange.response.setHeader("Allow", allow) + fail(exchange, 405, OidcFederationHttpErrorCode.REQUEST_INVALID, requestId) + } + + private suspend fun fail( + exchange: Exchange, + status: Int, + code: OidcFederationHttpErrorCode, + requestId: String + ) { + if (exchange.response.statusCode in 300..399 || exchange.response.statusCode in 400..599) return + secureResponse(exchange) + val error = OidcFederationHttpError(code = code, requestId = requestId) + exchange.response.statusCode = status + exchange.response.setHeader("Content-Type", "application/json; charset=utf-8") + exchange.response.write(ERROR_JSON.encodeToString(error)) + exchange.response.end() + } + + private suspend fun redirect(exchange: Exchange, location: String, status: Int) { + secureResponse(exchange) + exchange.response.statusCode = status + exchange.response.setHeader("Location", location) + exchange.response.end() + } + + private fun secureResponse(exchange: Exchange) { + exchange.response.setHeader("Cache-Control", "no-store") + exchange.response.setHeader("Pragma", "no-cache") + exchange.response.setHeader("Referrer-Policy", "no-referrer") + exchange.response.setHeader("X-Content-Type-Options", "nosniff") + } + + private fun stateCookie(value: String, maxAge: Long): Cookie = Cookie( + name = config.stateCookieName, + value = value, + path = "/", + maxAge = maxAge.coerceAtMost(900), + secure = true, + httpOnly = true, + sameSite = Cookie.SameSite.LAX + ) + + private fun clearStateCookie(): Cookie = stateCookie("", 0) + + private fun identitySessionCookie(value: String): Cookie = Cookie( + name = identityConfig.cookie.name, + value = value, + path = identityConfig.cookie.path, + domain = identityConfig.cookie.domain, + secure = identityConfig.cookie.secure, + httpOnly = true, + sameSite = identityConfig.cookie.sameSite.toCoreSameSite() + ) + + private fun csrfHandoffCookie(value: String): Cookie = Cookie( + name = config.csrfCookieName, + value = value, + path = "/", + maxAge = config.csrfCookieLifetimeSeconds, + secure = true, + httpOnly = false, + sameSite = Cookie.SameSite.LAX + ) + + private fun parseRoute(path: String): FederationRoute? { + if (path.length > 2_048 || '?' in path || '#' in path || !path.startsWith("$FEDERATION_BASE/")) return null + val segments = path.removePrefix("$FEDERATION_BASE/").split('/') + if (segments.size != 3 || segments.any(String::isEmpty)) return null + val tenant = OrganizationId.parseOrNull(segments[0]) ?: return null + if (!PROVIDER_ID.matches(segments[1])) return null + val action = when (segments[2]) { + "start" -> FederationAction.START + "callback" -> FederationAction.CALLBACK + else -> return null + } + return FederationRoute(tenant, segments[1], action) + } + + private fun isFederationPath(path: String): Boolean = + path == FEDERATION_BASE || path.startsWith("$FEDERATION_BASE/") + + private data class FederationRoute( + val tenantId: OrganizationId, + val providerId: String, + val action: FederationAction + ) + + private enum class FederationAction { START, CALLBACK } + + private companion object { + const val FEDERATION_BASE = "/identity/v1/federation" + const val STATE_SELECTOR_BYTES = 32 + const val CALLBACK_BINDING_BYTES = 32 + const val REQUEST_ID_BYTES = 12 + const val MAX_SELECTOR_ATTEMPTS = 3 + const val MAXIMUM_COOKIE_HEADER = 8_192 + const val MAXIMUM_COOKIE_FIELDS = 64 + val CALLBACK_QUERY_FIELDS = setOf("code", "state") + val PROVIDER_ID = Regex("[a-z0-9][a-z0-9_-]{0,62}") + val STATE_SELECTOR = Regex("[A-Za-z0-9_-]{43}") + val REQUEST_ID = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,254}") + val ERROR_JSON = Json { encodeDefaults = true; explicitNulls = false } + } +} + +private fun OidcFederationProviderRegistration.allowsAuthorizationRedirect(value: String): Boolean { + if (value.length > 8_192 || '#' in value || isProtocolControlIn(value)) return false + return allowedAuthorizationEndpoints.any { endpoint -> + value.startsWith("$endpoint?") && value.length > endpoint.length + 1 + } +} + +private fun requireRedirectEndpoint(value: String) { + require(value.length in 8..4_096 && '?' !in value && '#' !in value && !isProtocolControlIn(value)) { + "Invalid OIDC authorization endpoint" + } + IdentityHttpRequest(IdentityHttpMethod.GET, value) +} + +private fun requireSafeRedirect(value: String) { + require(value.length in 8..4_096 && '#' !in value && !isProtocolControlIn(value)) { + "Invalid OIDC success redirect" + } + IdentityHttpRequest(IdentityHttpMethod.GET, value) +} + +private fun decodeFormComponent(value: String): String { + val bytes = mutableListOf() + var index = 0 + while (index < value.length) { + when (val character = value[index]) { + '%' -> { + require(index + 2 < value.length) + val high = value[index + 1].digitToIntOrNull(16) ?: throw IllegalArgumentException() + val low = value[index + 2].digitToIntOrNull(16) ?: throw IllegalArgumentException() + bytes += ((high shl 4) or low).toByte() + index += 3 + } + '+' -> { + bytes += ' '.code.toByte() + index += 1 + } + else -> { + require(character.code !in 0xD800..0xDFFF) + bytes += character.toString().encodeToByteArray().toList() + index += 1 + } + } + } + return bytes.toByteArray().decodeToString(throwOnInvalidSequence = true) +} + +private fun isProtocolControl(character: Char): Boolean = character.code < 0x20 || character.code == 0x7f +private fun isProtocolControlIn(value: String): Boolean = value.any(::isProtocolControl) + +private fun SameSitePolicy.toCoreSameSite(): Cookie.SameSite = when (this) { + SameSitePolicy.STRICT -> Cookie.SameSite.STRICT + SameSitePolicy.LAX -> Cookie.SameSite.LAX + SameSitePolicy.NONE -> Cookie.SameSite.NONE +} + +private const val CALLBACK_BINDING_BYTES = 32 diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcIdTokenVerifier.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcIdTokenVerifier.kt new file mode 100644 index 0000000..75198cd --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcIdTokenVerifier.kt @@ -0,0 +1,224 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.Es256Signature +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.RsaSha256Signature +import kotlin.time.Instant + +internal data class VerifiedIdToken( + val claims: OidcVerifiedClaims, + val assertionDigest: ByteArray +) + +internal class OidcIdTokenVerifier( + private val config: OidcProviderConfig, + private val runtime: IdentityRuntime, + private val documents: OidcProviderDocuments +) { + suspend fun verify(idToken: String, expectedNonceDigest: ByteArray): VerifiedIdToken { + if (idToken.length !in 32..config.maximumIdTokenBytes || idToken.any(Char::isWhitespace)) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + val segments = idToken.split('.') + if (segments.size != 3 || segments.any(String::isEmpty)) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val headerBytes = decodeSegment(segments[0], 8_192) + val claimsBytes = decodeSegment(segments[1], 49_152) + val signatureBytes = decodeSegment(segments[2], 8_192) + try { + val header = BoundedJson.parseJwtObject(headerBytes, 8_192) + val claimsDocument = BoundedJson.parseJwtObject(claimsBytes, 49_152) + val algorithm = jwtString(header, "alg", 16) + if (algorithm != "ES256" && algorithm != "RS256") oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + if (algorithm !in documents.metadata().idTokenSigningAlgorithms) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + val keyId = jwtString(header, "kid", 255) + if (keyId.any { it.isWhitespace() || it.isProtocolControl() }) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val type = jwtOptionalString(header, "typ", 16) + if (type != null && type != "JWT") oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + if (header.members.keys.any { it in FORBIDDEN_JOSE_HEADERS }) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + + val signedData = "${segments[0]}.${segments[1]}".encodeToByteArray() + val signatureValid = try { + verifyWithRefresh(keyId, algorithm, signedData, signatureBytes) + } finally { + signedData.fill(0) + } + if (!signatureValid) oidcAbort(OidcErrorCode.SIGNATURE_INVALID) + + val claims = validateClaims(claimsDocument, expectedNonceDigest) + val digest = runtime.crypto.sha256(idToken.encodeToByteArray()) + if (digest.size != 32) { + digest.fill(0) + oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + } + return VerifiedIdToken(claims, digest) + } finally { + headerBytes.fill(0) + claimsBytes.fill(0) + signatureBytes.fill(0) + } + } + + private suspend fun verifyWithRefresh( + keyId: String, + algorithm: String, + signedData: ByteArray, + signature: ByteArray + ): Boolean { + val first = documents.verificationKey(keyId, algorithm) + if (first != null && verify(first, signedData, signature)) return true + val refreshed = documents.verificationKey(keyId, algorithm, forceRefresh = true) ?: return false + return verify(refreshed, signedData, signature) + } + + private suspend fun verify(key: OidcVerificationKey, signedData: ByteArray, signature: ByteArray): Boolean = + try { + when (key) { + is OidcVerificationKey.Es256 -> { + if (signature.size != 64) return false + runtime.crypto.verifyEs256(key.publicKey, signedData, Es256Signature(signature)) + } + is OidcVerificationKey.Rs256 -> { + if (signature.size != key.signatureSize || signature.size !in 256..1_024) return false + runtime.crypto.verifyRsaSha256(key.publicKey, signedData, RsaSha256Signature(signature)) + } + } + } catch (_: IllegalArgumentException) { + false + } + + private suspend fun validateClaims(document: JsonObjectValue, expectedNonceDigest: ByteArray): OidcVerifiedClaims { + val issuer = jwtString(document, "iss", 2_048) + if (issuer != config.issuer) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val subjectValue = jwtString(document, "sub", 1_024) + if (subjectValue.any(Char::isProtocolControl)) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val subject = try { + ExternalSubject(subjectValue) + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + val audiences = jwtAudience(document) + if (config.clientId !in audiences) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val authorizedParty = jwtOptionalString(document, "azp", 512) + if ((audiences.size > 1 && authorizedParty == null) || + (authorizedParty != null && authorizedParty != config.clientId) + ) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + + val expiresAtSeconds = jwtLong(document, "exp") + val issuedAtSeconds = jwtLong(document, "iat") + val expiresAt = instantFromEpochSeconds(expiresAtSeconds) + val issuedAt = instantFromEpochSeconds(issuedAtSeconds) + if (expiresAt <= issuedAt) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val now = runtime.clock.now() + if (expiresAt <= now - config.clockSkew || issuedAt > now + config.clockSkew) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + if (expiresAt - issuedAt > config.maximumIdTokenLifetime) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + val notBefore = jwtOptionalLong(document, "nbf") + if (notBefore != null && instantFromEpochSeconds(notBefore) > now + config.clockSkew) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + + val nonce = jwtString(document, "nonce", 512) + val actualNonceDigest = runtime.crypto.sha256(nonce.encodeToByteArray()) + val nonceMatches = try { + runtime.crypto.constantTimeEquals(actualNonceDigest, expectedNonceDigest) + } finally { + actualNonceDigest.fill(0) + } + if (!nonceMatches) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + + val email = jwtOptionalString(document, "email", 320) + ?.takeIf { it.none(Char::isProtocolControl) } + ?.let { raw -> + try { + EmailAddress(raw) + } catch (_: IllegalArgumentException) { + null + } + } + val displayName = jwtOptionalString(document, "name", 200) + ?.takeIf { it.isNotBlank() && it.none(Char::isProtocolControl) } + return OidcVerifiedClaims( + issuer = issuer, + subject = subject, + audiences = audiences, + authorizedParty = authorizedParty, + issuedAt = issuedAt, + expiresAt = expiresAt, + email = email, + displayName = displayName + ) + } + + private fun decodeSegment(value: String, maximumBytes: Int): ByteArray = try { + Base64Url.decode(value, maximumBytes) + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + + private fun jwtString(document: JsonObjectValue, name: String, maximumLength: Int): String { + val value = (document.members[name] as? JsonStringValue)?.value ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + if (value.isEmpty() || value.length > maximumLength) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + return value + } + + private fun jwtOptionalString(document: JsonObjectValue, name: String, maximumLength: Int): String? { + val raw = document.members[name] ?: return null + if (raw === JsonNullValue) return null + val value = (raw as? JsonStringValue)?.value ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + if (value.length > maximumLength) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + return value + } + + private fun jwtLong(document: JsonObjectValue, name: String): Long { + val source = (document.members[name] as? JsonNumberValue)?.source ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + if ('.' in source || 'e' in source || 'E' in source) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + return source.toLongOrNull() ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + + private fun jwtOptionalLong(document: JsonObjectValue, name: String): Long? { + val raw = document.members[name] ?: return null + if (raw === JsonNullValue) return null + val source = (raw as? JsonNumberValue)?.source ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + if ('.' in source || 'e' in source || 'E' in source) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + return source.toLongOrNull() ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + + private fun jwtAudience(document: JsonObjectValue): Set { + val raw = document.members["aud"] ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val audiences = when (raw) { + is JsonStringValue -> setOf(raw.value) + is JsonArrayValue -> { + if (raw.elements.isEmpty() || raw.elements.size > 32) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + raw.elements.map { (it as? JsonStringValue)?.value ?: oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) }.toSet() + } + else -> oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + if (audiences.isEmpty() || audiences.size > 32 || + audiences.any { it.isEmpty() || it.length > 512 || it.any(Char::isProtocolControl) } + ) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + if (raw is JsonArrayValue && audiences.size != raw.elements.size) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + return audiences + } + + private fun instantFromEpochSeconds(seconds: Long): Instant = try { + Instant.fromEpochSeconds(seconds) + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + } + + private companion object { + val FORBIDDEN_JOSE_HEADERS = setOf("crit", "jku", "jwk", "x5u", "x5c") + } +} diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcService.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcService.kt new file mode 100644 index 0000000..ddc03e5 --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcService.kt @@ -0,0 +1,574 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.AuditAction +import codes.yousef.aether.auth.AuditActor +import codes.yousef.aether.auth.AuditActorType +import codes.yousef.aether.auth.AuditEvent +import codes.yousef.aether.auth.AuditOutcome +import codes.yousef.aether.auth.AuditTarget +import codes.yousef.aether.auth.AuditTargetType +import codes.yousef.aether.auth.AcquireFederationProviderLeaseCommand +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.Challenge +import codes.yousef.aether.auth.ChallengePurpose +import codes.yousef.aether.auth.ChallengeState +import codes.yousef.aether.auth.ConsumeChallengeCommand +import codes.yousef.aether.auth.CreateChallengeCommand +import codes.yousef.aether.auth.DigestAlgorithm +import codes.yousef.aether.auth.ExternalIdentity +import codes.yousef.aether.auth.ExternalIdentityReplayReceipt +import codes.yousef.aether.auth.ExternalIdentityState +import codes.yousef.aether.auth.FederationJitProvisioning +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.IdentityIdFactory +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.IdentityStore +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.LinkExternalIdentityCommand +import codes.yousef.aether.auth.Membership +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.RecordExternalIdentityReplayCommand +import codes.yousef.aether.auth.SecretDigest +import codes.yousef.aether.auth.StoreResult +import codes.yousef.aether.auth.User +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.UserState +import kotlin.coroutines.cancellation.CancellationException + +/** Tenant-scoped Authorization Code + PKCE OIDC adapter. */ +class OidcIdentityProvider( + private val config: OidcProviderConfig, + private val runtime: IdentityRuntime, + private val store: IdentityStore +) : OidcFederationProvider { + override val configuredTenantId get() = config.tenantId + override val configuredProviderId get() = config.providerId + private val ids = IdentityIdFactory(runtime) + private val documents = OidcProviderDocuments(config, runtime) + private val tokenVerifier = OidcIdTokenVerifier(config, runtime, documents) + + override suspend fun beginAuthorization(request: OidcAuthorizationRequest): OidcResult = + runOidc { + requireConfiguredEnabled() + val now = runtime.clock.now() + val providerKey = providerStorageKey(config, runtime.crypto) + val providerLease = acquireProviderLease(providerKey, now) + val metadata = documents.metadata() + val expiresAt = now + config.transactionLifetime + val challengeId = ids.newChallengeId() + val stateEntropy = runtime.secureRandom.nextBytes(32) + val nonceBytes = runtime.secureRandom.nextBytes(32) + val verifierBytes = runtime.secureRandom.nextBytes(32) + if (stateEntropy.size != 32 || nonceBytes.size != 32 || verifierBytes.size != 32) { + stateEntropy.fill(0); nonceBytes.fill(0); verifierBytes.fill(0) + oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + } + try { + val state = "${challengeId.value}.${Base64Url.encode(stateEntropy)}" + val nonce = Base64Url.encode(nonceBytes) + val verifier = Base64Url.encode(verifierBytes) + val codeChallengeDigest = runtime.crypto.sha256(verifier.encodeToByteArray()) + val codeChallenge = try { + if (codeChallengeDigest.size != 32) oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + Base64Url.encode(codeChallengeDigest) + } finally { + codeChallengeDigest.fill(0) + } + val bindingBytes = request.callbackBindingBytes() + val challenge = try { + Challenge( + id = challengeId, + purpose = ChallengePurpose.EXTERNAL_IDENTITY_LINK, + challengeDigest = sha256Digest(state.encodeToByteArray()), + bindingDigest = sha256Digest(lengthPrefixed(providerKey.encodeToByteArray(), bindingBytes)), + payloadDigest = sha256Digest(nonce.encodeToByteArray()), + userId = request.linkToUserId, + organizationId = config.tenantId, + federationProviderLease = providerLease, + createdAt = now, + expiresAt = expiresAt + ) + } finally { + bindingBytes.fill(0) + } + when (val created = store.createChallenge(CreateChallengeCommand(challenge))) { + is StoreResult.Failure -> mapStoreFailure(created.error.code) + is StoreResult.Success -> Unit + } + val authorizationUrl = appendQuery( + metadata.authorizationEndpoint, + listOf( + "response_type" to "code", + "client_id" to config.clientId, + "redirect_uri" to config.redirectUri, + "scope" to config.scopes.sorted().joinToString(" "), + "state" to state, + "nonce" to nonce, + "code_challenge" to codeChallenge, + "code_challenge_method" to "S256" + ) + ) + OidcAuthorizationStart( + authorizationUrl = authorizationUrl, + callbackSecret = OidcCallbackSecret(challengeId, verifierBytes), + providerLease = providerLease, + expiresAt = expiresAt + ) + } finally { + stateEntropy.fill(0) + nonceBytes.fill(0) + verifierBytes.fill(0) + } + } + + override suspend fun completeAuthorization(request: OidcCallbackRequest): OidcResult = + runOidc { + requireConfiguredEnabled() + validateRequestLease(request.providerLease) + validateProviderLease(request.providerLease) + val challengeId = parseChallengeId(request.state) + if (request.callbackSecret.challengeId != challengeId) oidcAbort(OidcErrorCode.INVALID_STATE) + val challenge = loadPendingChallenge(challengeId, request.providerLease) + validateChallengeBinding(challenge, request, request.providerLease) + + // Every correctly bound callback attempt is single-use before network or assertion + // work. This prevents concurrent exchanges and makes malformed assertions fail closed. + val consumedAt = runtime.clock.now() + when (val consumed = store.consumeChallenge( + ConsumeChallengeCommand( + challengeId = challenge.id, + expectedVersion = challenge.version, + terminalState = ChallengeState.CONSUMED, + consumedAt = consumedAt, + federationProviderLease = request.providerLease + ) + )) { + is StoreResult.Success -> Unit + is StoreResult.Failure -> when (consumed.error.code) { + IdentityStoreErrorCode.CHALLENGE_EXPIRED -> oidcAbort(OidcErrorCode.TRANSACTION_EXPIRED) + IdentityStoreErrorCode.CHALLENGE_NOT_PENDING, + IdentityStoreErrorCode.NOT_FOUND, + IdentityStoreErrorCode.VERSION_CONFLICT, + IdentityStoreErrorCode.INVALID_TRANSITION -> oidcAbort(OidcErrorCode.INVALID_STATE) + else -> mapStoreFailure(consumed.error.code) + } + } + + val metadata = documents.metadata() + val idToken = request.callbackSecret.useVerifier { verifier -> + exchangeCode(metadata, request.authorizationCode, verifier) + } + + val nonceDigest = challenge.payloadDigest?.decodeSha256Digest() + ?: oidcAbort(OidcErrorCode.INVALID_STATE) + val verified = try { + tokenVerifier.verify(idToken, nonceDigest) + } finally { + nonceDigest.fill(0) + } + try { + validateProviderLease(request.providerLease) + resolveIdentity(challenge.userId, verified, request, request.providerLease) + } finally { + verified.assertionDigest.fill(0) + } + } + + private suspend fun resolveIdentity( + linkToUserId: UserId?, + verified: VerifiedIdToken, + request: OidcCallbackRequest, + providerLease: FederationProviderLease + ): OidcAuthenticationResult { + val providerKey = providerLease.storageKey + val subject = verified.claims.subject + val existing = when (val found = store.findExternalIdentity(providerKey, subject)) { + is StoreResult.Success -> found.value + is StoreResult.Failure -> mapStoreFailure(found.error.code) + } + val receivedAt = runtime.clock.now() + val replayExpiresAt = verified.claims.expiresAt + config.clockSkew + if (replayExpiresAt <= receivedAt) oidcAbort(OidcErrorCode.ID_TOKEN_INVALID) + val receipt = ExternalIdentityReplayReceipt( + id = ids.newExternalReplayReceiptId(), + provider = providerKey, + assertionDigest = SecretDigest(DigestAlgorithm.SHA256, Base64Url.encode(verified.assertionDigest)), + receivedAt = receivedAt, + expiresAt = replayExpiresAt + ) + + val identity = if (existing != null) { + if (existing.state != ExternalIdentityState.ACTIVE || + (linkToUserId != null && existing.userId != linkToUserId) + ) { + oidcAbort(OidcErrorCode.EXTERNAL_IDENTITY_CONFLICT) + } + when (val replay = store.recordExternalIdentityReplay( + RecordExternalIdentityReplayCommand(receipt, providerLease) + )) { + is StoreResult.Success -> Unit + is StoreResult.Failure -> mapReplayFailure(replay.error.code) + } + existing + } else { + val now = runtime.clock.now() + val jitProvisioning = if (linkToUserId == null) { + newJitProvisioning(verified.claims, now) + } else { + requireActiveUser(linkToUserId) + null + } + val userId = linkToUserId ?: requireNotNull(jitProvisioning).user.id + val created = ExternalIdentity( + id = ids.newExternalIdentityId(), + userId = userId, + provider = providerKey, + subject = subject, + email = verified.claims.email, + createdAt = now, + updatedAt = now, + lastAuthenticatedAt = now + ) + val actor = if (linkToUserId == null) { + AuditActor(AuditActorType.SYSTEM) + } else { + AuditActor(AuditActorType.USER, userId = linkToUserId) + } + val audit = AuditEvent( + id = ids.newAuditEventId(), + actor = actor, + organizationId = config.tenantId, + action = AuditAction.EXTERNAL_IDENTITY_LINKED, + target = AuditTarget(AuditTargetType.EXTERNAL_IDENTITY, created.id.value), + outcome = AuditOutcome.SUCCEEDED, + request = request.auditRequest, + occurredAt = now + ) + when (val linked = store.linkExternalIdentity( + LinkExternalIdentityCommand( + identity = created, + replayReceipt = receipt, + federationProviderLease = providerLease, + auditEvent = audit, + jitProvisioning = jitProvisioning + ) + )) { + is StoreResult.Success -> { + if (jitProvisioning != null && + (linked.value.provisionedUser != jitProvisioning.user || + linked.value.provisionedMembership != jitProvisioning.membership) + ) { + oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + } + linked.value.identity + } + is StoreResult.Failure -> when (linked.error.code) { + IdentityStoreErrorCode.REPLAY_DETECTED -> oidcAbort(OidcErrorCode.ASSERTION_REPLAYED) + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + IdentityStoreErrorCode.NOT_FOUND -> oidcAbort(OidcErrorCode.PROVIDER_DISABLED) + IdentityStoreErrorCode.ALREADY_EXISTS, + IdentityStoreErrorCode.UNIQUE_CONSTRAINT -> oidcAbort(OidcErrorCode.EXTERNAL_IDENTITY_CONFLICT) + else -> mapStoreFailure(linked.error.code) + } + } + } + requireActiveUser(identity.userId) + return OidcAuthenticationResult( + userId = identity.userId, + externalIdentityId = identity.id, + providerLease = providerLease, + claims = verified.claims + ) + } + + private fun newJitProvisioning(claims: OidcVerifiedClaims, now: kotlin.time.Instant): FederationJitProvisioning { + if (!config.jitProvisioningEnabled) oidcAbort(OidcErrorCode.EXTERNAL_IDENTITY_NOT_LINKED) + val userId = ids.newUserId() + val user = User( + id = userId, + state = UserState.ACTIVE, + displayName = claims.displayName?.trim()?.takeIf(String::isNotEmpty) ?: "Federated user", + primaryEmail = null, + createdAt = now, + updatedAt = now, + activatedAt = now + ) + val membership = Membership( + id = ids.newMembershipId(), + organizationId = config.tenantId, + userId = userId, + role = OrganizationRole.VIEWER, + createdAt = now, + updatedAt = now + ) + return FederationJitProvisioning(user, membership) + } + + private suspend fun requireActiveUser(userId: UserId) { + val user = when (val found = store.findUser(userId)) { + is StoreResult.Success -> found.value + is StoreResult.Failure -> mapStoreFailure(found.error.code) + } + if (user == null || user.state != UserState.ACTIVE) oidcAbort(OidcErrorCode.EXTERNAL_IDENTITY_CONFLICT) + } + + private suspend fun loadPendingChallenge( + id: codes.yousef.aether.auth.ChallengeId, + providerLease: FederationProviderLease + ): Challenge { + val challenge = when (val found = store.findChallenge(id)) { + is StoreResult.Success -> found.value + is StoreResult.Failure -> if (found.error.code == IdentityStoreErrorCode.NOT_FOUND) { + oidcAbort(OidcErrorCode.INVALID_STATE) + } else { + mapStoreFailure(found.error.code) + } + } ?: oidcAbort(OidcErrorCode.INVALID_STATE) + if (challenge.purpose != ChallengePurpose.EXTERNAL_IDENTITY_LINK || + challenge.organizationId != config.tenantId || + challenge.federationProviderLease != providerLease || + challenge.state != ChallengeState.PENDING + ) { + oidcAbort(OidcErrorCode.INVALID_STATE) + } + if (challenge.expiresAt <= runtime.clock.now()) oidcAbort(OidcErrorCode.TRANSACTION_EXPIRED) + return challenge + } + + private suspend fun validateChallengeBinding( + challenge: Challenge, + request: OidcCallbackRequest, + providerLease: FederationProviderLease + ) { + val stateDigest = runtime.crypto.sha256(request.state.encodeToByteArray()) + val stateMatches = try { + compareDigest(challenge.challengeDigest, stateDigest) + } finally { + stateDigest.fill(0) + } + if (!stateMatches) oidcAbort(OidcErrorCode.INVALID_STATE) + + val binding = request.callbackBindingBytes() + val encodedBinding = lengthPrefixed(providerLease.storageKey.encodeToByteArray(), binding) + binding.fill(0) + val bindingDigest = try { + runtime.crypto.sha256(encodedBinding) + } finally { + encodedBinding.fill(0) + } + val bindingMatches = try { + compareDigest(challenge.bindingDigest, bindingDigest) + } finally { + bindingDigest.fill(0) + } + if (!bindingMatches) oidcAbort(OidcErrorCode.INVALID_STATE) + } + + private suspend fun exchangeCode(metadata: OidcMetadata, code: String, verifier: String): String { + val parameters = mutableListOf( + "grant_type" to "authorization_code", + "code" to code, + "redirect_uri" to config.redirectUri, + "client_id" to config.clientId, + "code_verifier" to verifier + ) + val headers = mutableMapOf( + "Accept" to "application/json", + "Content-Type" to "application/x-www-form-urlencoded" + ) + config.clientSecretReference?.let { reference -> + val secret = try { + runtime.secrets.resolve(reference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + } + secret.useBytes { bytes -> + if (bytes.size !in 1..4_096) oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + val credentials = percentEncode(config.clientId).encodeToByteArray() + byteArrayOf(':'.code.toByte()) + + percentEncode(bytes).encodeToByteArray() + try { + headers["Authorization"] = "Basic ${standardBase64(credentials)}" + } finally { + credentials.fill(0) + } + } + parameters.removeAll { it.first == "client_id" } + } + val body = formEncode(parameters) + val response = try { + runtime.http.execute( + IdentityHttpRequest( + IdentityHttpMethod.POST, + metadata.tokenEndpoint, + headers, + body, + maximumResponseBytes = config.maximumTokenResponseBytes + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + } finally { + body.fill(0) + headers["Authorization"] = "" + } + if (response.statusCode !in 200..299) oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + val responseBytes = response.bodyBytes() + if (responseBytes.size > config.maximumTokenResponseBytes) { + responseBytes.fill(0) + oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + } + val document = try { + BoundedJson.parseObject(responseBytes, config.maximumTokenResponseBytes) + } catch (_: OidcAbort) { + oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + } finally { + responseBytes.fill(0) + } + val tokenType = document.optionalString("token_type", 32) + if (tokenType != null && !tokenType.equals("Bearer", ignoreCase = true)) { + oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + } + return try { + document.requiredString("id_token", config.maximumIdTokenBytes) + } catch (_: OidcAbort) { + oidcAbort(OidcErrorCode.TOKEN_EXCHANGE_FAILED) + } + } + + private suspend fun sha256Digest(value: ByteArray): SecretDigest { + val digest = try { + runtime.crypto.sha256(value) + } finally { + value.fill(0) + } + return try { + if (digest.size != 32) oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + SecretDigest(DigestAlgorithm.SHA256, Base64Url.encode(digest)) + } finally { + digest.fill(0) + } + } + + private suspend fun compareDigest(expected: SecretDigest, actual: ByteArray): Boolean { + if (expected.algorithm != DigestAlgorithm.SHA256 || expected.keyVersion != null) return false + val expectedBytes = try { + Base64Url.decode(expected.encoded, 32) + } catch (_: IllegalArgumentException) { + return false + } + return try { + expectedBytes.size == 32 && actual.size == 32 && runtime.crypto.constantTimeEquals(expectedBytes, actual) + } finally { + expectedBytes.fill(0) + } + } + + private fun SecretDigest.decodeSha256Digest(): ByteArray { + if (algorithm != DigestAlgorithm.SHA256 || keyVersion != null) oidcAbort(OidcErrorCode.INVALID_STATE) + return try { + Base64Url.decode(encoded, 32).also { if (it.size != 32) oidcAbort(OidcErrorCode.INVALID_STATE) } + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.INVALID_STATE) + } + } + + private fun parseChallengeId(state: String): codes.yousef.aether.auth.ChallengeId { + val separator = state.indexOf('.') + if (separator <= 0 || separator == state.lastIndex || state.indexOf('.', separator + 1) >= 0) { + oidcAbort(OidcErrorCode.INVALID_STATE) + } + if (state.substring(separator + 1).length != 43) oidcAbort(OidcErrorCode.INVALID_STATE) + try { + Base64Url.decode(state.substring(separator + 1), 32).also { + if (it.size != 32) oidcAbort(OidcErrorCode.INVALID_STATE) + it.fill(0) + } + return codes.yousef.aether.auth.ChallengeId.parse(state.substring(0, separator)) + } catch (_: IllegalArgumentException) { + oidcAbort(OidcErrorCode.INVALID_STATE) + } + } + + private fun requireConfiguredEnabled() { + if (!config.enabled) oidcAbort(OidcErrorCode.PROVIDER_DISABLED) + } + + private suspend fun acquireProviderLease( + storageKey: String, + acquiredAt: kotlin.time.Instant + ): FederationProviderLease = when (val acquired = store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organizationId = config.tenantId, + kind = FederationProviderKind.OIDC, + providerId = config.providerId, + storageKey = storageKey, + acquiredAt = acquiredAt + ) + )) { + is StoreResult.Success -> acquired.value + is StoreResult.Failure -> mapProviderLeaseFailure(acquired.error.code) + } + + private suspend fun validateRequestLease(lease: FederationProviderLease) { + if (lease.organizationId != config.tenantId || + lease.kind != FederationProviderKind.OIDC || + lease.providerId != config.providerId || + lease.storageKey != providerStorageKey(config, runtime.crypto) + ) { + oidcAbort(OidcErrorCode.INVALID_STATE) + } + } + + private suspend fun validateProviderLease(lease: FederationProviderLease) { + when (val validated = store.validateFederationProviderLease(lease)) { + is StoreResult.Success -> if (validated.value != lease) oidcAbort(OidcErrorCode.INVALID_STATE) + is StoreResult.Failure -> mapProviderLeaseFailure(validated.error.code) + } + } + + private fun mapProviderLeaseFailure(code: IdentityStoreErrorCode): Nothing = when (code) { + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + IdentityStoreErrorCode.NOT_FOUND, + IdentityStoreErrorCode.ALREADY_EXISTS, + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + IdentityStoreErrorCode.INVALID_TRANSITION -> oidcAbort(OidcErrorCode.PROVIDER_DISABLED) + else -> mapStoreFailure(code) + } + + private fun mapReplayFailure(code: IdentityStoreErrorCode): Nothing = when (code) { + IdentityStoreErrorCode.REPLAY_DETECTED, + IdentityStoreErrorCode.ALREADY_EXISTS, + IdentityStoreErrorCode.UNIQUE_CONSTRAINT -> oidcAbort(OidcErrorCode.ASSERTION_REPLAYED) + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, + IdentityStoreErrorCode.NOT_FOUND -> oidcAbort(OidcErrorCode.PROVIDER_DISABLED) + else -> mapStoreFailure(code) + } + + private fun mapStoreFailure(code: IdentityStoreErrorCode): Nothing = when (code) { + IdentityStoreErrorCode.UNAVAILABLE, + IdentityStoreErrorCode.INTERNAL, + IdentityStoreErrorCode.VERSION_CONFLICT -> oidcAbort(OidcErrorCode.STORE_UNAVAILABLE) + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED -> oidcAbort(OidcErrorCode.PROVIDER_DISABLED) + IdentityStoreErrorCode.REPLAY_DETECTED -> oidcAbort(OidcErrorCode.ASSERTION_REPLAYED) + else -> oidcAbort(OidcErrorCode.EXTERNAL_IDENTITY_CONFLICT) + } +} + +private suspend fun runOidc(block: suspend () -> T): OidcResult = try { + OidcResult.Success(block()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: OidcAbort) { + OidcResult.Failure(OidcError(failure.code)) +} catch (_: IllegalArgumentException) { + OidcResult.Failure(OidcError(OidcErrorCode.INVALID_CALLBACK)) +} catch (_: Exception) { + OidcResult.Failure(OidcError(OidcErrorCode.STORE_UNAVAILABLE)) +} diff --git a/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcTypes.kt b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcTypes.kt new file mode 100644 index 0000000..a8fca16 --- /dev/null +++ b/aether-auth-oidc/src/commonMain/kotlin/codes/yousef/aether/auth/oidc/OidcTypes.kt @@ -0,0 +1,333 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.AuthenticationAssurance +import codes.yousef.aether.auth.AuditRequestMetadata +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.ExternalIdentityId +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.SecretReference +import codes.yousef.aether.auth.SessionAuthenticationMethod +import codes.yousef.aether.auth.UserId +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.EncodeDefault + +private val PROVIDER_ID_PATTERN = Regex("[a-z0-9][a-z0-9_-]{0,62}") +private val SCOPE_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}") + +/** Immutable configuration for one tenant-scoped OpenID Provider. */ +class OidcProviderConfig( + val tenantId: OrganizationId, + val providerId: String, + val issuer: String, + val clientId: String, + val redirectUri: String, + scopes: Set = setOf("openid", "profile", "email"), + val clientSecretReference: SecretReference? = null, + val enabled: Boolean = true, + val jitProvisioningEnabled: Boolean = false, + val transactionLifetime: Duration = 5.minutes, + val clockSkew: Duration = 30.seconds, + val maximumIdTokenLifetime: Duration = 24.hours, + val discoveryCacheLifetime: Duration = 1.hours, + val jwksCacheLifetime: Duration = 15.minutes, + val maximumDiscoveryBytes: Int = 65_536, + val maximumJwksBytes: Int = 262_144, + val maximumTokenResponseBytes: Int = 65_536, + val maximumIdTokenBytes: Int = 98_304, + allowedEndpointOrigins: Set = setOf(oidcEndpointOrigin(issuer)) +) { + val scopes: Set = scopes.toSet() + val allowedEndpointOrigins: Set = allowedEndpointOrigins.toSet() + + init { + require(PROVIDER_ID_PATTERN.matches(providerId)) { "Invalid OIDC provider ID" } + require(clientId.isNotBlank() && clientId.length <= 512 && clientId.none(Char::isProtocolControl)) { + "Invalid OIDC client ID" + } + requireCanonicalIssuer(issuer) + requireSafeCallbackUri(redirectUri) + require("openid" in this.scopes) { "OIDC scopes must include openid" } + require(this.scopes.isNotEmpty() && this.scopes.size <= 32 && this.scopes.all(SCOPE_PATTERN::matches)) { + "Invalid OIDC scope set" + } + require(transactionLifetime.isPositive() && transactionLifetime <= 15.minutes) { + "OIDC transaction lifetime must be in 1ns..15m" + } + require(!clockSkew.isNegative() && clockSkew <= 5.minutes) { "OIDC clock skew must be in 0s..5m" } + require(maximumIdTokenLifetime.isPositive() && maximumIdTokenLifetime <= 24.hours) { + "Maximum ID-token lifetime must be in 1ns..24h" + } + require(discoveryCacheLifetime.isPositive() && discoveryCacheLifetime <= 24.hours) + require(jwksCacheLifetime.isPositive() && jwksCacheLifetime <= 24.hours) + require(maximumDiscoveryBytes in 1_024..1_048_576) + require(maximumJwksBytes in 1_024..2_097_152) + require(maximumTokenResponseBytes in 1_024..1_048_576) + require(maximumIdTokenBytes in 1_024..262_144) + require(this.allowedEndpointOrigins.isNotEmpty() && this.allowedEndpointOrigins.size <= 16) { + "OIDC endpoint-origin allowlist must contain 1..16 exact origins" + } + this.allowedEndpointOrigins.forEach { origin -> + require(origin.length <= 2_048 && origin == oidcEndpointOrigin(origin)) { + "OIDC endpoint origins must be canonical scheme/authority values" + } + } + } + + override fun toString(): String = + "OidcProviderConfig(tenantId=$tenantId, providerId=$providerId, issuer=$issuer, " + + "clientId=, redirectUri=, enabled=$enabled, " + + "jitProvisioningEnabled=$jitProvisioningEnabled)" +} + +internal fun oidcEndpointOrigin(value: String): String { + codes.yousef.aether.auth.IdentityHttpRequest( + codes.yousef.aether.auth.IdentityHttpMethod.GET, + value + ) + val separator = value.indexOf("://") + val authorityStart = separator + 3 + val authorityEnd = listOf(value.indexOf('/', authorityStart), value.indexOf('?', authorityStart), + value.indexOf('#', authorityStart)) + .filter { it >= 0 } + .minOrNull() ?: value.length + val scheme = value.substring(0, separator).lowercase() + val authority = value.substring(authorityStart, authorityEnd).lowercase() + return "$scheme://$authority" +} + +@Serializable +enum class OidcErrorCode { + @SerialName("provider_disabled") PROVIDER_DISABLED, + @SerialName("discovery_unavailable") DISCOVERY_UNAVAILABLE, + @SerialName("provider_metadata_invalid") PROVIDER_METADATA_INVALID, + @SerialName("invalid_callback") INVALID_CALLBACK, + @SerialName("invalid_state") INVALID_STATE, + @SerialName("transaction_expired") TRANSACTION_EXPIRED, + @SerialName("token_exchange_failed") TOKEN_EXCHANGE_FAILED, + @SerialName("id_token_invalid") ID_TOKEN_INVALID, + @SerialName("signature_invalid") SIGNATURE_INVALID, + @SerialName("assertion_replayed") ASSERTION_REPLAYED, + @SerialName("external_identity_not_linked") EXTERNAL_IDENTITY_NOT_LINKED, + @SerialName("external_identity_conflict") EXTERNAL_IDENTITY_CONFLICT, + @SerialName("provisioning_failed") PROVISIONING_FAILED, + @SerialName("store_unavailable") STORE_UNAVAILABLE +} + +/** Safe protocol failure. Provider payloads, token values, and exception messages never appear here. */ +@Serializable +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +data class OidcError( + val code: OidcErrorCode, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val message: String = code.genericMessage, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val retryable: Boolean = code.defaultRetryable +) { + init { + require(message == code.genericMessage) { "OIDC errors must use the stable generic message" } + require(retryable == code.defaultRetryable) { "OIDC retryability is fixed by error code" } + } +} + +sealed interface OidcResult { + data class Success(val value: T) : OidcResult + data class Failure(val error: OidcError) : OidcResult + + fun valueOrNull(): T? = (this as? Success)?.value +} + +internal val OidcErrorCode.genericMessage: String + get() = when (this) { + OidcErrorCode.PROVIDER_DISABLED -> "The identity provider is unavailable." + OidcErrorCode.DISCOVERY_UNAVAILABLE -> "The identity provider could not be reached." + OidcErrorCode.PROVIDER_METADATA_INVALID -> "The identity provider configuration is invalid." + OidcErrorCode.INVALID_CALLBACK -> "The identity response is invalid." + OidcErrorCode.INVALID_STATE -> "The identity request is invalid or has already been used." + OidcErrorCode.TRANSACTION_EXPIRED -> "The identity request has expired." + OidcErrorCode.TOKEN_EXCHANGE_FAILED -> "The identity response could not be completed." + OidcErrorCode.ID_TOKEN_INVALID -> "The identity response is invalid." + OidcErrorCode.SIGNATURE_INVALID -> "The identity response signature is invalid." + OidcErrorCode.ASSERTION_REPLAYED -> "The identity response has already been used." + OidcErrorCode.EXTERNAL_IDENTITY_NOT_LINKED -> "The external identity is not linked." + OidcErrorCode.EXTERNAL_IDENTITY_CONFLICT -> "The external identity cannot be linked." + OidcErrorCode.PROVISIONING_FAILED -> "The external identity could not be provisioned." + OidcErrorCode.STORE_UNAVAILABLE -> "The identity service is temporarily unavailable." + } + +internal val OidcErrorCode.defaultRetryable: Boolean + get() = this == OidcErrorCode.DISCOVERY_UNAVAILABLE || + this == OidcErrorCode.STORE_UNAVAILABLE + +/** Narrow provider surface consumed by the common-code HTTP middleware. */ +interface OidcFederationProvider { + val configuredTenantId: OrganizationId + val configuredProviderId: String + + suspend fun beginAuthorization(request: OidcAuthorizationRequest): OidcResult + suspend fun completeAuthorization(request: OidcCallbackRequest): OidcResult +} + +/** Caller-held, server-side PKCE material. It must be stored in an encrypted or integrity-protected cookie/store. */ +class OidcCallbackSecret internal constructor( + val challengeId: ChallengeId, + verifier: ByteArray +) { + private val verifierValue = verifier.copyOf() + + init { require(verifierValue.size == 32) { "OIDC PKCE verifier seed must be 32 bytes" } } + + internal suspend fun useVerifier(block: suspend (String) -> T): T { + return useSeedForProtection { seed -> block(codes.yousef.aether.auth.Base64Url.encode(seed)) } + } + + /** + * Supplies a temporary verifier-seed copy to an application-owned authenticated-encryption + * boundary. The resulting envelope may be persisted in an HttpOnly callback cookie or a + * server-side distributed store; the raw seed must never be sent or logged. + */ + suspend fun useSeedForProtection(block: suspend (ByteArray) -> T): T { + val copy = verifierValue.copyOf() + return try { + block(copy) + } finally { + copy.fill(0) + } + } + + internal fun destroy() { + verifierValue.fill(0) + } + + override fun toString(): String = "OidcCallbackSecret()" + + companion object { + /** Restores a callback secret after application-owned authenticated decryption. */ + fun restore(challengeId: ChallengeId, verifierSeed: ByteArray): OidcCallbackSecret = + OidcCallbackSecret(challengeId, verifierSeed) + } +} + +class OidcAuthorizationStart internal constructor( + val authorizationUrl: String, + val callbackSecret: OidcCallbackSecret, + val providerLease: FederationProviderLease, + val expiresAt: Instant +) { + init { + require(providerLease.kind == FederationProviderKind.OIDC) { + "OIDC authorization requires an OIDC provider lease" + } + } + + override fun toString(): String = + "OidcAuthorizationStart(authorizationUrl=, callbackSecret=, " + + "providerLease=$providerLease, expiresAt=$expiresAt)" +} + +class OidcAuthorizationRequest( + callbackBinding: ByteArray, + val linkToUserId: UserId? = null +) { + private val callbackBindingValue = callbackBinding.copyOf() + + init { require(callbackBindingValue.size in 16..1_024) { "OIDC callback binding must be 16..1024 bytes" } } + + internal fun callbackBindingBytes(): ByteArray = callbackBindingValue.copyOf() + override fun toString(): String = "OidcAuthorizationRequest(callbackBinding=, linkToUserId=$linkToUserId)" +} + +class OidcCallbackRequest( + val state: String, + val authorizationCode: String, + callbackBinding: ByteArray, + val callbackSecret: OidcCallbackSecret, + val providerLease: FederationProviderLease, + val auditRequest: AuditRequestMetadata? = null +) { + private val callbackBindingValue = callbackBinding.copyOf() + + init { + require(state.length in 16..512 && state.none { it.isWhitespace() || it.isProtocolControl() }) { + "Invalid OIDC state" + } + require(authorizationCode.length in 1..8_192 && + authorizationCode.none { it.isWhitespace() || it.isProtocolControl() } + ) { + "Invalid OIDC authorization code" + } + require(callbackBindingValue.size in 16..1_024) { "OIDC callback binding must be 16..1024 bytes" } + require(providerLease.kind == FederationProviderKind.OIDC) { + "OIDC callbacks require an OIDC provider lease" + } + } + + internal fun callbackBindingBytes(): ByteArray = callbackBindingValue.copyOf() + override fun toString(): String = + "OidcCallbackRequest(state=, authorizationCode=, callbackBinding=, " + + "callbackSecret=, auditRequest=$auditRequest)" +} + +data class OidcVerifiedClaims( + val issuer: String, + val subject: ExternalSubject, + val audiences: Set, + val authorizedParty: String?, + val issuedAt: Instant, + val expiresAt: Instant, + val email: EmailAddress?, + val displayName: String? +) { + override fun toString(): String = + "OidcVerifiedClaims(issuer=$issuer, subject=, audiences=, " + + "authorizedParty=, issuedAt=$issuedAt, expiresAt=$expiresAt, " + + "email=${if (email == null) "none" else ""}, displayName=${if (displayName == null) "none" else ""})" +} + +data class OidcAuthenticationResult( + val userId: UserId, + val externalIdentityId: ExternalIdentityId, + val providerLease: FederationProviderLease, + val assurance: AuthenticationAssurance = AuthenticationAssurance.SESSION, + val authenticationMethod: SessionAuthenticationMethod = SessionAuthenticationMethod.OIDC, + val passkeyStepUpRequiredForSensitiveActions: Boolean = true, + val claims: OidcVerifiedClaims +) { + init { + require(providerLease.kind == FederationProviderKind.OIDC && + assurance == AuthenticationAssurance.SESSION && + authenticationMethod == SessionAuthenticationMethod.OIDC && + passkeyStepUpRequiredForSensitiveActions + ) { "OIDC authentication results require an OIDC session lease and passkey step-up" } + } +} + +private fun requireCanonicalIssuer(value: String) { + require(value.length in 8..2_048 && value == value.trim() && !value.endsWith('/')) { "Invalid OIDC issuer" } + require(value.none { it.isWhitespace() || it.isProtocolControl() }) { "Invalid OIDC issuer" } + require('?' !in value && '#' !in value && '@' !in value.substringAfter("://", "")) { "Invalid OIDC issuer" } + // Reuse the identity runtime's strict HTTPS/loopback validation without performing I/O. + codes.yousef.aether.auth.IdentityHttpRequest( + codes.yousef.aether.auth.IdentityHttpMethod.GET, + value + ) +} + +private fun requireSafeCallbackUri(value: String) { + require(value.length in 8..4_096 && '#' !in value && + value.none { it.isWhitespace() || it.isProtocolControl() } + ) { "Invalid OIDC redirect URI" } + codes.yousef.aether.auth.IdentityHttpRequest( + codes.yousef.aether.auth.IdentityHttpMethod.GET, + value + ) +} diff --git a/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcErrorContractTest.kt b/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcErrorContractTest.kt new file mode 100644 index 0000000..6e72789 --- /dev/null +++ b/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcErrorContractTest.kt @@ -0,0 +1,60 @@ +package codes.yousef.aether.auth.oidc + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class OidcErrorContractTest { + @Test + fun `protocol errors own their stable message and retryability`() { + val failure = OidcError(OidcErrorCode.STORE_UNAVAILABLE) + + assertEquals("The identity service is temporarily unavailable.", failure.message) + assertTrue(failure.retryable) + val encoded = Json.encodeToString(failure) + assertTrue("\"message\":\"The identity service is temporarily unavailable.\"" in encoded) + assertTrue("\"retryable\":true" in encoded) + assertEquals(failure, Json.decodeFromString(encoded)) + assertFailsWith { + OidcError(OidcErrorCode.STORE_UNAVAILABLE, "provider exception: secret", true) + } + assertFailsWith { + OidcError(OidcErrorCode.INVALID_CALLBACK, retryable = true) + } + } + + @Test + fun `HTTP errors preserve the complete stable wire envelope`() { + val failure = OidcFederationHttpError( + code = OidcFederationHttpErrorCode.SERVICE_UNAVAILABLE, + requestId = "request-123" + ) + + val encoded = Json.encodeToString(failure) + + assertTrue("\"code\":\"service_unavailable\"" in encoded) + assertTrue("\"message\":\"The identity service is temporarily unavailable.\"" in encoded) + assertTrue("\"requestId\":\"request-123\"" in encoded) + assertTrue("\"retryable\":true" in encoded) + assertEquals(failure, Json.decodeFromString(encoded)) + assertFailsWith { + OidcFederationHttpError( + code = OidcFederationHttpErrorCode.REQUEST_INVALID, + message = "provider exception: secret", + requestId = "request-123", + retryable = false + ) + } + assertFailsWith { + OidcFederationHttpError( + code = OidcFederationHttpErrorCode.REQUEST_INVALID, + requestId = "request-123", + retryable = true + ) + } + } +} diff --git a/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcFederationHttpMiddlewareTest.kt b/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcFederationHttpMiddlewareTest.kt new file mode 100644 index 0000000..741350d --- /dev/null +++ b/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcFederationHttpMiddlewareTest.kt @@ -0,0 +1,576 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.AuditAction +import codes.yousef.aether.auth.AuthenticationAssurance +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.ExternalIdentityId +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.FederationCallbackStateConsumeResult +import codes.yousef.aether.auth.FederationCallbackStateStore +import codes.yousef.aether.auth.FederationCallbackStateWriteResult +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.FederatedIdentitySessionService +import codes.yousef.aether.auth.IdentityConfig +import codes.yousef.aether.auth.IdentityContext +import codes.yousef.aether.auth.IdentityContextAttributeKey +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.IdentityKeyConfig +import codes.yousef.aether.auth.IdentityPrincipal +import codes.yousef.aether.auth.IdentityPrincipalKind +import codes.yousef.aether.auth.IdentitySession +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.RelyingPartyConfig +import codes.yousef.aether.auth.SecretReference +import codes.yousef.aether.auth.SessionAuthenticationMethod +import codes.yousef.aether.auth.SessionState +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.DeterministicIdentitySecretResolver +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.InMemoryIdentityStore +import codes.yousef.aether.auth.testkit.InMemoryIdentityStoreSeed +import codes.yousef.aether.core.Attributes +import codes.yousef.aether.core.Cookie +import codes.yousef.aether.core.Cookies +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.Headers +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.Request +import codes.yousef.aether.core.RequestConnection +import codes.yousef.aether.core.Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +class OidcFederationHttpMiddlewareTest { + @Test + fun `start and callback keep PKCE state server-side and create provenance session`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + + assertEquals(302, start.response.statusCode) + assertTrue(start.response.headers.build()["Location"]!!.startsWith("$AUTHORIZATION_ENDPOINT?")) + val stateCookie = start.response.cookies.single { it.name == STATE_COOKIE } + assertTrue(stateCookie.secure) + assertTrue(stateCookie.httpOnly) + assertEquals(Cookie.SameSite.LAX, stateCookie.sameSite) + assertEquals(43, stateCookie.value.length) + assertFalse(start.response.headers.build()["Location"]!!.contains("verifier", ignoreCase = true)) + assertFalse(start.response.bodyText().contains("secret", ignoreCase = true)) + assertEquals(1, fixture.states.size) + + val callback = fixture.callback(stateCookie.value) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(303, callback.response.statusCode) + assertEquals(SUCCESS_REDIRECT, callback.response.headers.build()["Location"]) + assertFalse(callback.response.headers.build()["Location"]!!.contains("csrf", ignoreCase = true)) + val sessionCookie = callback.response.cookies.single { it.name == fixture.config.cookie.name } + val csrfCookie = callback.response.cookies.single { it.name == CSRF_COOKIE } + assertTrue(sessionCookie.secure) + assertTrue(sessionCookie.httpOnly) + assertEquals(Cookie.SameSite.LAX, sessionCookie.sameSite) + assertTrue(csrfCookie.secure) + assertFalse(csrfCookie.httpOnly) + assertEquals(300, csrfCookie.maxAge) + assertEquals(0, fixture.states.size) + + val snapshot = fixture.store.snapshot() + val session = snapshot.sessions.single() + assertEquals(AuthenticationAssurance.SESSION, session.assurance) + assertEquals(SessionAuthenticationMethod.OIDC, session.authenticationMethod) + assertEquals(TENANT_ID, session.federationOrganizationId) + assertEquals(PROVIDER_STORAGE_KEY, session.federationProviderKey) + assertEquals(EXTERNAL_IDENTITY_ID, session.externalIdentityId) + assertEquals(USER_ID, session.userId) + assertEquals(AuditAction.SESSION_CREATED, snapshot.auditEvents.single().action) + assertFalse(session.tokenDigest.encoded in sessionCookie.value) + fixture.provider.callbackSecretSeen!!.useSeedForProtection { seed -> + assertTrue(seed.all { it == 0.toByte() }) + } + } + + @Test + fun `callback rotates the authenticated predecessor before setting the federated cookie`() = runTest { + val fixture = Fixture(withPredecessor = true) + val start = fixture.authenticate(fixture.exchange(HttpMethod.GET, fixture.startPath)) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(303, callback.response.statusCode) + val predecessor = requireNotNull(fixture.predecessor) + val snapshot = fixture.store.snapshot() + val rotated = snapshot.sessions.single { it.id == predecessor.id } + val replacement = snapshot.sessions.single { it.id != predecessor.id } + assertEquals(SessionState.ROTATED, rotated.state) + assertEquals(replacement.id, rotated.rotatedToId) + assertEquals(predecessor.id, replacement.rotatedFromId) + assertEquals(predecessor.familyId, replacement.familyId) + assertEquals(SessionAuthenticationMethod.OIDC, replacement.authenticationMethod) + assertEquals(AuditAction.SESSION_ROTATED, snapshot.auditEvents.single().action) + } + + @Test + fun `callback rejects a federated user without an active tenant membership`() = runTest { + val fixture = Fixture(membershipState = MembershipState.SUSPENDED) + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(400, callback.response.statusCode) + assertEquals(emptyList(), fixture.store.snapshot().sessions) + assertEquals(emptyList(), fixture.store.snapshot().auditEvents) + assertGenericError(callback) + } + + @Test + fun `callback state is tenant-bound and atomically single use`() = runTest { + val fixture = Fixture(includeSecondProvider = true) + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val mismatch = fixture.callback(selector, providerId = "other") + fixture.middleware.asMiddleware()(mismatch) { error("Federation route must not fall through") } + assertEquals(400, mismatch.response.statusCode) + assertEquals(0, fixture.otherProvider.callbackCount) + assertEquals(0, fixture.states.size) + + val replay = fixture.callback(selector) + fixture.middleware.asMiddleware()(replay) { error("Federation route must not fall through") } + assertEquals(400, replay.response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + assertEquals(0, fixture.store.snapshot().sessions.size) + assertGenericError(replay) + } + + @Test + fun `dynamic kill switch is checked again on callback and consumes correlation`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.enabled = false + + val disabled = fixture.callback(selector) + fixture.middleware.asMiddleware()(disabled) { error("Federation route must not fall through") } + + assertEquals(404, disabled.response.statusCode) + assertEquals(1, fixture.provider.callbackCount) + assertEquals(0, fixture.states.size) + assertEquals(0, fixture.store.snapshot().sessions.size) + assertGenericError(disabled) + } + + @Test + fun `strict route query method and body bounds reject malformed callbacks`() = runTest { + val fixture = Fixture() + suspend fun execute(exchange: TestExchange): TestExchange { + fixture.middleware.asMiddleware()(exchange) { error("Federation route must not fall through") } + return exchange + } + + assertEquals(404, execute(fixture.exchange(HttpMethod.GET, "/identity/v1/federation/$TENANT_ID/bad!/start")).response.statusCode) + assertEquals(405, execute(fixture.exchange(HttpMethod.POST, fixture.startPath)).response.statusCode) + assertEquals( + 400, + execute(fixture.exchange(HttpMethod.GET, fixture.startPath, query = "returnTo=https://evil.test")).response.statusCode + ) + + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + val duplicate = fixture.callback( + selector, + query = "state=$PROVIDER_STATE&state=attacker-state-value&code=$AUTHORIZATION_CODE" + ) + assertEquals(400, execute(duplicate).response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + + val oversized = fixture.callback(selector, query = "state=$PROVIDER_STATE&code=${"a".repeat(12_289)}") + assertEquals(400, execute(oversized).response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + + val declaredBody = fixture.callback( + selector, + headers = Headers.of("Content-Length" to "1", "Cookie" to "$STATE_COOKIE=$selector"), + body = byteArrayOf(1) + ) + assertEquals(400, execute(declaredBody).response.statusCode) + assertEquals(0, declaredBody.requestValue.bodyReads) + } + + @Test + fun `provider exceptions and credential material are redacted from generic errors`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.callbackFailure = IllegalStateException( + "leaked $AUTHORIZATION_CODE with PKCE verifier super-secret-verifier" + ) + + val failed = fixture.callback(selector) + fixture.middleware.asMiddleware()(failed) { error("Federation route must not fall through") } + + assertEquals(503, failed.response.statusCode) + assertGenericError(failed) + val body = failed.response.bodyText() + assertFalse(body.contains(AUTHORIZATION_CODE)) + assertFalse(body.contains("verifier", ignoreCase = true)) + assertFalse(body.contains("super-secret", ignoreCase = true)) + assertEquals(0, fixture.store.snapshot().sessions.size) + } + + @Test + fun `registry configuration mismatch fails closed while not-owned routes compose`() = runTest { + val mismatched = Fixture(mismatchedRegistration = true) + val denied = mismatched.exchange(HttpMethod.GET, mismatched.startPath) + var deniedFallthrough = false + mismatched.middleware.asMiddleware()(denied) { deniedFallthrough = true } + assertEquals(404, denied.response.statusCode) + assertFalse(deniedFallthrough) + + val notOwned = Fixture(notOwned = true) + val delegated = notOwned.exchange(HttpMethod.GET, notOwned.startPath) + var continued = false + notOwned.middleware.asMiddleware()(delegated) { continued = true } + assertTrue(continued) + assertEquals(200, delegated.response.statusCode) + assertEquals(0, delegated.requestValue.bodyReads) + } + + @Test + fun `provider redirect outside the configured endpoint allowlist is never emitted`() = runTest { + val fixture = Fixture() + fixture.provider.authorizationUrl = "https://attacker.example.test/authorize?state=stolen" + val exchange = fixture.exchange(HttpMethod.GET, fixture.startPath) + + fixture.middleware.asMiddleware()(exchange) { error("Federation route must not fall through") } + + assertEquals(503, exchange.response.statusCode) + assertEquals(null, exchange.response.headers.build()["Location"]) + assertEquals(0, fixture.states.size) + assertGenericError(exchange) + } + + private class Fixture( + includeSecondProvider: Boolean = false, + mismatchedRegistration: Boolean = false, + private val notOwned: Boolean = false, + withPredecessor: Boolean = false, + membershipState: MembershipState = MembershipState.ACTIVE + ) { + val config = identityConfig() + private val secretResolver = DeterministicIdentitySecretResolver( + mapOf(config.keys.sessionPepper to ByteArray(32) { 0x42 }) + ) + private val deterministic = DeterministicIdentityRuntime(deterministicSecrets = secretResolver) + private val user = IdentityFixtures.user(USER_ID) + private val organization = IdentityFixtures.organization(TENANT_ID) + private val membership = IdentityFixtures.membership( + organizationId = TENANT_ID, + userId = USER_ID, + state = membershipState + ) + private val providerControl = IdentityFixtures.federationProviderControl( + organizationId = TENANT_ID, + kind = FederationProviderKind.OIDC, + providerId = PROVIDER_ID, + storageKey = PROVIDER_STORAGE_KEY + ) + private val otherProviderControl = IdentityFixtures.federationProviderControl( + organizationId = TENANT_ID, + kind = FederationProviderKind.OIDC, + providerId = "other", + storageKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "other" + ) + ) + val predecessor: IdentitySession? = if (withPredecessor) { + IdentityFixtures.session( + id = IdentityFixtures.sessionId("oidc-callback-predecessor"), + userId = USER_ID + ) + } else { + null + } + val store = InMemoryIdentityStore( + InMemoryIdentityStoreSeed( + users = listOf(user), + sessions = listOfNotNull(predecessor), + organizations = listOf(organization), + memberships = listOf(membership), + federationProviderControls = listOf(providerControl) + + listOfNotNull(otherProviderControl.takeIf { includeSecondProvider }) + ) + ) + val states = InMemoryCallbackStates() + val provider = FakeProvider( + PROVIDER_ID, + deterministic, + IdentityFixtures.federationProviderLease(providerControl) + ) + val otherProvider = FakeProvider( + "other", + deterministic, + IdentityFixtures.federationProviderLease(otherProviderControl) + ) + private val registrations = buildMap { + put(PROVIDER_ID, registration(if (mismatchedRegistration) otherProvider else provider)) + if (includeSecondProvider) put("other", registration(otherProvider)) + } + val middleware = OidcFederationHttpMiddleware( + runtime = deterministic.runtime, + identityConfig = config, + providers = OidcFederationProviderRegistry { tenantId, providerId -> + if (notOwned) { + OidcFederationProviderResolution.NotOwned + } else { + registrations[providerId]?.takeIf { tenantId == TENANT_ID } + ?.let(OidcFederationProviderResolution::Found) + ?: OidcFederationProviderResolution.Missing + } + }, + callbackStates = states, + sessions = FederatedIdentitySessionService(store, deterministic.runtime, config) + ) + val startPath = "/identity/v1/federation/${TENANT_ID.value}/$PROVIDER_ID/start" + + fun exchange( + method: HttpMethod, + path: String, + query: String? = null, + headers: Headers = Headers.Empty, + cookies: Cookies = Cookies.Empty, + body: ByteArray = ByteArray(0) + ): TestExchange = TestExchange(method, path, query, headers, cookies, body) + + fun authenticate(exchange: TestExchange): TestExchange = exchange.also { authenticated -> + predecessor?.let { session -> + authenticated.attributes.put( + IdentityContextAttributeKey, + IdentityContext( + principal = IdentityPrincipal( + kind = IdentityPrincipalKind.USER, + userId = user.id, + displayName = user.displayName, + assurance = session.assurance, + authenticatedAt = session.authenticatedAt, + sessionId = session.id + ), + session = session + ) + ) + } + } + + fun callback( + selector: String, + providerId: String = PROVIDER_ID, + query: String = "state=$PROVIDER_STATE&code=$AUTHORIZATION_CODE", + headers: Headers = Headers.of("Cookie" to "$STATE_COOKIE=$selector", "User-Agent" to "Test Browser/1.0"), + body: ByteArray = ByteArray(0) + ): TestExchange = exchange( + HttpMethod.GET, + "/identity/v1/federation/${TENANT_ID.value}/$providerId/callback", + query, + headers, + Cookies.of(Cookie(STATE_COOKIE, selector)), + body + ) + + private fun registration(provider: FakeProvider) = OidcFederationProviderRegistration( + provider = provider, + allowedAuthorizationEndpoints = setOf(AUTHORIZATION_ENDPOINT), + successRedirectUrl = SUCCESS_REDIRECT + ) + } + + private class FakeProvider( + override val configuredProviderId: String, + private val deterministic: DeterministicIdentityRuntime, + private val providerLease: FederationProviderLease + ) : OidcFederationProvider { + override val configuredTenantId: OrganizationId = TENANT_ID + var enabled: Boolean = true + var callbackCount: Int = 0 + var callbackFailure: Throwable? = null + var authorizationUrl: String = "$AUTHORIZATION_ENDPOINT?client_id=test&state=$PROVIDER_STATE" + var callbackSecretSeen: OidcCallbackSecret? = null + + override suspend fun beginAuthorization(request: OidcAuthorizationRequest): OidcResult { + if (!enabled) return OidcResult.Failure(OidcError(OidcErrorCode.PROVIDER_DISABLED)) + return OidcResult.Success( + OidcAuthorizationStart( + authorizationUrl = authorizationUrl, + callbackSecret = OidcCallbackSecret.restore(ChallengeId("challenge-$configuredProviderId"), ByteArray(32) { 0x5a }), + providerLease = providerLease, + expiresAt = deterministic.deterministicClock.now() + 5.minutes + ) + ) + } + + override suspend fun completeAuthorization(request: OidcCallbackRequest): OidcResult { + callbackCount += 1 + callbackSecretSeen = request.callbackSecret + callbackFailure?.let { throw it } + if (!enabled) return OidcResult.Failure(OidcError(OidcErrorCode.PROVIDER_DISABLED)) + if (request.state != PROVIDER_STATE || request.authorizationCode != AUTHORIZATION_CODE || + request.providerLease != providerLease + ) { + return OidcResult.Failure(OidcError(OidcErrorCode.INVALID_CALLBACK)) + } + val now = deterministic.deterministicClock.now() + return OidcResult.Success( + OidcAuthenticationResult( + userId = USER_ID, + externalIdentityId = EXTERNAL_IDENTITY_ID, + providerLease = providerLease, + claims = OidcVerifiedClaims( + issuer = "https://issuer.example.test", + subject = ExternalSubject("external-subject"), + audiences = setOf("client"), + authorizedParty = null, + issuedAt = now, + expiresAt = now + 5.minutes, + email = EmailAddress("user@example.test"), + displayName = "Test User" + ) + ) + ) + } + } + + private class InMemoryCallbackStates : FederationCallbackStateStore { + private val values = mutableMapOf() + val size: Int get() = values.size + + override suspend fun store( + selector: String, + state: OidcServerCallbackState + ): FederationCallbackStateWriteResult = if (values.containsKey(selector)) { + FederationCallbackStateWriteResult.Conflict + } else { + values[selector] = state + FederationCallbackStateWriteResult.Stored + } + + override suspend fun consume( + selector: String + ): FederationCallbackStateConsumeResult = + values.remove(selector)?.let { FederationCallbackStateConsumeResult.Consumed(it) } + ?: FederationCallbackStateConsumeResult.Missing + } + + private companion object { + val TENANT_ID = OrganizationId("01900000-0000-7000-8000-000000000100") + val USER_ID = UserId("01900000-0000-7000-8000-000000000101") + val EXTERNAL_IDENTITY_ID = ExternalIdentityId("01900000-0000-7000-8000-000000000102") + const val PROVIDER_ID = "workforce" + val PROVIDER_STORAGE_KEY = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + PROVIDER_ID + ) + const val PROVIDER_STATE = "provider-state-value-12345" + const val AUTHORIZATION_CODE = "authorization-code-value" + const val AUTHORIZATION_ENDPOINT = "https://login.example.test/oauth2/authorize" + const val SUCCESS_REDIRECT = "https://identity.example.test/account/security" + const val STATE_COOKIE = "__Host-aether_oidc_state" + const val CSRF_COOKIE = "__Host-aether_csrf" + } +} + +private class TestRequest( + override val method: HttpMethod, + override val path: String, + override val query: String?, + override val headers: Headers, + override val cookies: Cookies, + private val body: ByteArray +) : Request { + override val uri: String = if (query == null) path else "$path?$query" + override val connection: RequestConnection = + RequestConnection("https", "identity.example.test", "127.0.0.1") + var bodyReads: Int = 0 + private set + + override suspend fun bodyBytes(): ByteArray { + bodyReads += 1 + return body.copyOf() + } +} + +private class TestResponse : Response { + override var statusCode: Int = 200 + override var statusMessage: String? = null + override val headers = Headers.HeadersBuilder() + override val cookies = mutableListOf() + private val body = mutableListOf() + + override suspend fun write(data: ByteArray) { body += data.toList() } + override suspend fun end() = Unit + fun bodyText(): String = body.toByteArray().decodeToString() +} + +private class TestExchange( + method: HttpMethod, + path: String, + query: String?, + headers: Headers, + cookies: Cookies, + body: ByteArray +) : Exchange { + val requestValue = TestRequest(method, path, query, headers, cookies, body) + override val request: Request = requestValue + override val response = TestResponse() + override val attributes = Attributes() +} + +private fun identityConfig(): IdentityConfig { + fun secret(name: String) = SecretReference("test", name, "v1", IdentityEnvironment.TEST) + return IdentityConfig( + environment = IdentityEnvironment.TEST, + publicBaseUrl = "https://identity.example.test", + relyingParty = RelyingPartyConfig( + id = "identity.example.test", + name = "OIDC middleware test", + allowedOrigins = setOf("https://identity.example.test") + ), + keys = IdentityKeyConfig( + sessionPepper = secret("session"), + recoveryPepper = secret("recovery"), + deviceTokenPepper = secret("device"), + serviceCredentialPepper = secret("service"), + auditPseudonymizationKey = secret("audit"), + encryptionKey = secret("encryption"), + signingKey = secret("signing") + ) + ) +} + +private fun assertGenericError(exchange: TestExchange) { + val payload = Json.parseToJsonElement(exchange.response.bodyText()).jsonObject + assertEquals(setOf("code", "message", "requestId", "retryable"), payload.keys) + assertFalse(payload.getValue("message").toString().contains("secret", ignoreCase = true)) + assertEquals("no-store", exchange.response.headers.build()["Cache-Control"]) + assertNotNull(exchange.response.headers.build()["X-Content-Type-Options"]) +} diff --git a/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcIdentityProviderTest.kt b/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcIdentityProviderTest.kt new file mode 100644 index 0000000..e5fe368 --- /dev/null +++ b/aether-auth-oidc/src/commonTest/kotlin/codes/yousef/aether/auth/oidc/OidcIdentityProviderTest.kt @@ -0,0 +1,588 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.ExternalIdentity +import codes.yousef.aether.auth.ExternalIdentityLinkCommit +import codes.yousef.aether.auth.ExternalIdentityReplayReceipt +import codes.yousef.aether.auth.ExternalReplayReceiptId +import codes.yousef.aether.auth.FederationJitProvisioning +import codes.yousef.aether.auth.IdentityFederationProviderManager +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpResponse +import codes.yousef.aether.auth.IdentityStore +import codes.yousef.aether.auth.IdentityStoreError +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.LinkExternalIdentityCommand +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.RecordExternalIdentityReplayCommand +import codes.yousef.aether.auth.SecretDigest +import codes.yousef.aether.auth.SessionAuthenticationMethod +import codes.yousef.aether.auth.DigestAlgorithm +import codes.yousef.aether.auth.StoreResult +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.testkit.DeterministicIdentityCrypto +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.InMemoryIdentityStore +import codes.yousef.aether.auth.testkit.InMemoryIdentityStoreSeed +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.minutes +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OidcIdentityProviderTest { + @Test + fun authorizationCodePkceRefreshesJwksAndAuthenticatesLinkedSubject() = runTest { + val fixture = fixture(linked = true) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + assertContains(start.authorizationUrl, "code_challenge_method=S256") + assertContains(start.authorizationUrl, "response_type=code") + + fixture.http.enqueue(jsonResponse(tokenResponse(idToken(nonce = nonce, keyId = "rotated-key")))) + fixture.http.enqueue(jsonResponse(jwks("old-key"))) + fixture.http.enqueue(jsonResponse(jwks("rotated-key"))) + + val result = fixture.provider.completeAuthorization( + callback(start, state, fixture.binding) + ) + val authenticated = assertIs>(result).value + assertEquals(USER_ID, authenticated.userId) + assertEquals(SessionAuthenticationMethod.OIDC, authenticated.authenticationMethod) + assertTrue(authenticated.passkeyStepUpRequiredForSensitiveActions) + + val requests = fixture.http.recordedRequests() + assertEquals(4, requests.size) + assertEquals(IdentityHttpMethod.POST, requests[1].method) + assertEquals(65_536, requests[0].maximumResponseBytes) + assertEquals(65_536, requests[1].maximumResponseBytes) + assertEquals(262_144, requests[2].maximumResponseBytes) + assertEquals(262_144, requests[3].maximumResponseBytes) + val tokenBody = requests[1].bodyBytes().decodeToString() + assertContains(tokenBody, "grant_type=authorization_code") + assertContains(tokenBody, "code_verifier=") + assertEquals(2, requests.count { it.url == JWKS_URI }) + + val snapshot = fixture.store.snapshot() + assertEquals(1, snapshot.replayReceipts.size) + val consumedChallenge = snapshot.challenges.single() + assertEquals(codes.yousef.aether.auth.ChallengeState.CONSUMED, consumedChallenge.state) + assertEquals(start.providerLease, consumedChallenge.federationProviderLease) + } + + @Test + fun discoveryAndJwksCachesAreReusedWithinTheirConfiguredLifetime() = runTest { + val fixture = fixture(linked = true) + val first = fixture.begin() + fixture.http.enqueue( + jsonResponse(tokenResponse(idToken(queryParameter(first.authorizationUrl, "nonce")))) + ) + fixture.http.enqueue(jsonResponse(jwks("active-key"))) + assertIs>( + fixture.provider.completeAuthorization( + callback(first, queryParameter(first.authorizationUrl, "state"), fixture.binding) + ) + ) + + val second = fixture.begin() + fixture.http.enqueue( + jsonResponse(tokenResponse(idToken(queryParameter(second.authorizationUrl, "nonce")))) + ) + assertIs>( + fixture.provider.completeAuthorization( + callback(second, queryParameter(second.authorizationUrl, "state"), fixture.binding) + ) + ) + + val requests = fixture.http.recordedRequests() + assertEquals(1, requests.count { it.url.endsWith("/.well-known/openid-configuration") }) + assertEquals(1, requests.count { it.url == JWKS_URI }) + assertEquals(2, fixture.store.snapshot().replayReceipts.size) + } + + @Test + fun validatesIssuerAudienceAuthorizedPartyNonceAndTimeExactly() = runTest { + val cases = listOf<(String, Long) -> String>( + { nonce, now -> claimsJson(nonce, now, issuer = "https://wrong.example.test") }, + { nonce, now -> claimsJson(nonce, now, audienceJson = "\"different-client\"") }, + { nonce, now -> claimsJson(nonce, now, audienceJson = "[\"$CLIENT_ID\",\"other\"]") }, + { _, now -> claimsJson("wrong-nonce", now) }, + { nonce, now -> claimsJson(nonce, now, expiresAt = now - 120) }, + { nonce, now -> claimsJson(nonce, now, issuedAt = now + 120) } + ) + + for (claims in cases) { + val fixture = fixture(linked = true) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + val now = fixture.runtime.deterministicClock.now().epochSeconds + fixture.http.enqueue(jsonResponse(tokenResponse(jwt(claims(nonce, now), "active-key", "ES256")))) + fixture.http.enqueue(jsonResponse(jwks("active-key"))) + + val failure = assertIs( + fixture.provider.completeAuthorization(callback(start, state, fixture.binding)) + ) + assertEquals(OidcErrorCode.ID_TOKEN_INVALID, failure.error.code) + assertEquals(0, fixture.store.snapshot().replayReceipts.size) + } + } + + @Test + fun requiresExactDiscoveryIssuerAndPkceS256() = runTest { + val runtime = DeterministicIdentityRuntime() + val providerConfig = config() + val store = emptyProviderStore(providerConfig) + val provider = OidcIdentityProvider(providerConfig, runtime.runtime, store) + runtime.deterministicHttp.enqueue( + jsonResponse(discovery(issuer = "https://different.example.test")) + ) + val wrongIssuer = assertIs(provider.beginAuthorization(request(BINDING))) + assertEquals(OidcErrorCode.PROVIDER_METADATA_INVALID, wrongIssuer.error.code) + + val secondRuntime = DeterministicIdentityRuntime() + val secondConfig = config() + val secondProvider = OidcIdentityProvider( + secondConfig, + secondRuntime.runtime, + emptyProviderStore(secondConfig) + ) + secondRuntime.deterministicHttp.enqueue( + jsonResponse(discovery(pkceMethods = "[\"plain\"]")) + ) + val noS256 = assertIs(secondProvider.beginAuthorization(request(BINDING))) + assertEquals(OidcErrorCode.PROVIDER_METADATA_INVALID, noS256.error.code) + + val redirectedRuntime = DeterministicIdentityRuntime() + val redirectedProvider = OidcIdentityProvider( + providerConfig, + redirectedRuntime.runtime, + emptyProviderStore(providerConfig) + ) + redirectedRuntime.deterministicHttp.enqueue( + jsonResponse(discovery(tokenEndpoint = "https://credential-sink.example.test/token")) + ) + val redirectedTokenEndpoint = assertIs( + redirectedProvider.beginAuthorization(request(BINDING)) + ) + assertEquals(OidcErrorCode.PROVIDER_METADATA_INVALID, redirectedTokenEndpoint.error.code) + assertEquals(1, redirectedRuntime.deterministicHttp.recordedRequests().size) + } + + @Test + fun jitIsOffByDefaultAndEmailNeverLinksAnExistingUser() = runTest { + val fixture = fixture(linked = false) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + fixture.http.enqueue(jsonResponse(tokenResponse(idToken(nonce)))) + fixture.http.enqueue(jsonResponse(jwks("active-key"))) + + val failure = assertIs( + fixture.provider.completeAuthorization(callback(start, state, fixture.binding)) + ) + assertEquals(OidcErrorCode.EXTERNAL_IDENTITY_NOT_LINKED, failure.error.code) + assertTrue(fixture.store.snapshot().externalIdentities.isEmpty()) + } + + @Test + fun enabledJitAtomicallyCreatesANullEmailViewerAndNeverMergesByEmail() = runTest { + val fixture = fixture(linked = false, jitProvisioningEnabled = true, existingEmailUser = true) + val authenticated = assertIs>( + fixture.complete() + ).value + + assertTrue(authenticated.userId != USER_ID) + val snapshot = fixture.store.snapshot() + assertEquals(2, snapshot.users.size) + assertEquals("user-1@example.test", snapshot.users.single { it.id == USER_ID }.primaryEmail?.value) + val provisioned = snapshot.users.single { it.id == authenticated.userId } + assertEquals(null, provisioned.primaryEmail) + assertEquals("OIDC User", provisioned.displayName) + val membership = snapshot.memberships.single { it.userId == authenticated.userId } + assertEquals(fixture.config.tenantId, membership.organizationId) + assertEquals(OrganizationRole.VIEWER, membership.role) + assertEquals(authenticated.userId, snapshot.externalIdentities.single().userId) + } + + @Test + fun jitLinkFailureLeavesNoOrphanUserOrMembership() = runTest { + val runtime = DeterministicIdentityRuntime() + val config = config(jitProvisioningEnabled = true) + val delegate = emptyProviderStore(config) + val store = RejectingJitLinkStore(delegate) + val provider = OidcIdentityProvider(config, runtime.runtime, store) + runtime.deterministicHttp.enqueue(jsonResponse(discovery())) + val fixture = Fixture(runtime, delegate, provider, config, BINDING.copyOf(), linkOnBegin = false) + + val failure = assertIs(fixture.complete()) + assertEquals(OidcErrorCode.EXTERNAL_IDENTITY_CONFLICT, failure.error.code) + assertTrue(store.capturedProvisioning != null) + val snapshot = delegate.snapshot() + assertTrue(snapshot.users.isEmpty()) + assertTrue(snapshot.memberships.isEmpty()) + assertTrue(snapshot.externalIdentities.isEmpty()) + assertTrue(snapshot.replayReceipts.isEmpty()) + } + + @Test + fun explicitLinkUsesTenantProviderIssuerAndSubjectKey() = runTest { + val fixture = fixture(linked = false, linkOnBegin = true) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + fixture.http.enqueue(jsonResponse(tokenResponse(idToken(nonce)))) + fixture.http.enqueue(jsonResponse(jwks("active-key"))) + + val success = assertIs>( + fixture.provider.completeAuthorization(callback(start, state, fixture.binding)) + ).value + assertEquals(USER_ID, success.userId) + assertEquals(fixture.providerKey(), success.providerLease.storageKey) + val stored = fixture.store.snapshot().externalIdentities.single() + assertEquals(fixture.providerKey(), stored.provider) + assertEquals(SUBJECT, stored.subject) + } + + @Test + fun supportsRs256JwkAndRejectsInvalidSignatures() = runTest { + val rsaFixture = fixture(linked = true) + val rsaStart = rsaFixture.begin() + val rsaState = queryParameter(rsaStart.authorizationUrl, "state") + val rsaNonce = queryParameter(rsaStart.authorizationUrl, "nonce") + rsaFixture.http.enqueue(jsonResponse(tokenResponse(idToken(rsaNonce, "rsa-key", "RS256")))) + rsaFixture.http.enqueue(jsonResponse(rsaJwks("rsa-key"))) + assertIs>( + rsaFixture.provider.completeAuthorization(callback(rsaStart, rsaState, rsaFixture.binding)) + ) + + val runtime = DeterministicIdentityRuntime( + deterministicCrypto = DeterministicIdentityCrypto(verifyEs256Result = false) + ) + val invalidFixture = fixture(linked = true, runtime = runtime) + val invalidStart = invalidFixture.begin() + val invalidState = queryParameter(invalidStart.authorizationUrl, "state") + val invalidNonce = queryParameter(invalidStart.authorizationUrl, "nonce") + invalidFixture.http.enqueue(jsonResponse(tokenResponse(idToken(invalidNonce)))) + invalidFixture.http.enqueue(jsonResponse(jwks("active-key"))) + invalidFixture.http.enqueue(jsonResponse(jwks("active-key"))) + val failure = assertIs( + invalidFixture.provider.completeAuthorization( + callback(invalidStart, invalidState, invalidFixture.binding) + ) + ) + assertEquals(OidcErrorCode.SIGNATURE_INVALID, failure.error.code) + } + + @Test + fun rejectsDuplicateSecurityClaimsBeforeVerification() = runTest { + val fixture = fixture(linked = true) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + val now = fixture.runtime.deterministicClock.now().epochSeconds + val duplicateIssuerClaims = claimsJson(nonce, now).dropLast(1) + ",\"iss\":\"$ISSUER\"}" + fixture.http.enqueue(jsonResponse(tokenResponse(jwt(duplicateIssuerClaims, "active-key", "ES256")))) + fixture.http.enqueue(jsonResponse(jwks("active-key"))) + + val failure = assertIs( + fixture.provider.completeAuthorization(callback(start, state, fixture.binding)) + ) + assertEquals(OidcErrorCode.ID_TOKEN_INVALID, failure.error.code) + } + + @Test + fun storeReplayReceiptRejectsAPreviouslyAcceptedAssertionDigest() = runTest { + val fixture = fixture(linked = true) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + val token = idToken(nonce) + val digest = fixture.runtime.runtime.crypto.sha256(token.encodeToByteArray()) + assertIs>(fixture.store.recordExternalIdentityReplay( + RecordExternalIdentityReplayCommand( + ExternalIdentityReplayReceipt( + id = ExternalReplayReceiptId("existing-replay-receipt"), + provider = fixture.providerKey(), + assertionDigest = SecretDigest(DigestAlgorithm.SHA256, Base64Url.encode(digest)), + receivedAt = fixture.runtime.deterministicClock.now(), + expiresAt = fixture.runtime.deterministicClock.now() + 10.minutes + ), + start.providerLease + ) + )) + digest.fill(0) + fixture.http.enqueue(jsonResponse(tokenResponse(token))) + fixture.http.enqueue(jsonResponse(jwks("active-key"))) + + val failure = assertIs( + fixture.provider.completeAuthorization(callback(start, state, fixture.binding)) + ) + assertEquals(OidcErrorCode.ASSERTION_REPLAYED, failure.error.code) + } + + @Test + fun callbackBindingAndProviderLeaseInvalidationAreEnforcedBeforeExchange() = runTest { + val fixture = fixture(linked = true) + val start = fixture.begin() + val state = queryParameter(start.authorizationUrl, "state") + val wrongBinding = ByteArray(32) { 99 } + val bindingFailure = assertIs( + fixture.provider.completeAuthorization(callback(start, state, wrongBinding)) + ) + assertEquals(OidcErrorCode.INVALID_STATE, bindingFailure.error.code) + assertEquals(1, fixture.http.recordedRequests().size) + + val runtime = DeterministicIdentityRuntime() + val config = config() + val store = emptyProviderStore(config) + val provider = OidcIdentityProvider( + config = config, + runtime = runtime.runtime, + store = store + ) + runtime.deterministicHttp.enqueue(jsonResponse(discovery())) + val enabledStart = assertIs>( + provider.beginAuthorization(OidcAuthorizationRequest(BINDING)) + ).value + val manager = IdentityFederationProviderManager(store, runtime.runtime) + assertIs>( + manager.disableProvider( + organizationId = enabledStart.providerLease.organizationId, + kind = enabledStart.providerLease.kind, + providerId = enabledStart.providerLease.providerId, + storageKey = enabledStart.providerLease.storageKey + ) + ) + val disabled = assertIs( + provider.completeAuthorization( + callback( + enabledStart, + queryParameter(enabledStart.authorizationUrl, "state"), + BINDING + ) + ) + ) + assertEquals(OidcErrorCode.PROVIDER_DISABLED, disabled.error.code) + assertEquals(1, runtime.deterministicHttp.recordedRequests().size) + + assertIs>( + manager.enableProvider( + organizationId = enabledStart.providerLease.organizationId, + kind = enabledStart.providerLease.kind, + providerId = enabledStart.providerLease.providerId, + storageKey = enabledStart.providerLease.storageKey + ) + ) + val staleAfterReenable = assertIs( + provider.completeAuthorization( + callback( + enabledStart, + queryParameter(enabledStart.authorizationUrl, "state"), + BINDING + ) + ) + ) + assertEquals(OidcErrorCode.PROVIDER_DISABLED, staleAfterReenable.error.code) + assertEquals(1, runtime.deterministicHttp.recordedRequests().size) + assertEquals( + codes.yousef.aether.auth.ChallengeState.PENDING, + store.snapshot().challenges.single().state + ) + } + + private suspend fun fixture( + linked: Boolean, + linkOnBegin: Boolean = false, + runtime: DeterministicIdentityRuntime = DeterministicIdentityRuntime(), + jitProvisioningEnabled: Boolean = false, + existingEmailUser: Boolean = false + ): Fixture { + val config = config(jitProvisioningEnabled) + val providerKey = providerStorageKey(config, runtime.runtime.crypto) + val user = IdentityFixtures.user(USER_ID).let { fixtureUser -> + if (existingEmailUser) { + fixtureUser.copy(primaryEmail = codes.yousef.aether.auth.EmailAddress("user-1@example.test")) + } else { + fixtureUser + } + } + val externalIdentity = IdentityFixtures.externalIdentity( + userId = USER_ID, + provider = providerKey, + subject = SUBJECT + ) + val store = InMemoryIdentityStore( + InMemoryIdentityStoreSeed( + users = listOf(user), + organizations = listOf(providerOrganization(config)), + externalIdentities = if (linked) listOf(externalIdentity) else emptyList() + ) + ) + val provider = OidcIdentityProvider(config, runtime.runtime, store) + runtime.deterministicHttp.enqueue(jsonResponse(discovery())) + return Fixture(runtime, store, provider, config, BINDING.copyOf(), linkOnBegin) + } + + private inner class Fixture( + val runtime: DeterministicIdentityRuntime, + val store: InMemoryIdentityStore, + val provider: OidcIdentityProvider, + val config: OidcProviderConfig, + val binding: ByteArray, + val linkOnBegin: Boolean + ) { + val http get() = runtime.deterministicHttp + suspend fun providerKey(): String = store.snapshot().federationProviderControls.single().storageKey + + suspend fun begin(): OidcAuthorizationStart = assertIs>( + provider.beginAuthorization( + OidcAuthorizationRequest(binding, linkToUserId = if (linkOnBegin) USER_ID else null) + ) + ).value + + suspend fun complete(): OidcResult { + val start = begin() + val state = queryParameter(start.authorizationUrl, "state") + val nonce = queryParameter(start.authorizationUrl, "nonce") + runtime.deterministicHttp.enqueue(jsonResponse(tokenResponse(idToken(nonce)))) + runtime.deterministicHttp.enqueue(jsonResponse(jwks("active-key"))) + return provider.completeAuthorization(callback(start, state, binding)) + } + } + + private fun config(jitProvisioningEnabled: Boolean = false) = OidcProviderConfig( + tenantId = OrganizationId("tenant-1"), + providerId = "workforce", + issuer = ISSUER, + clientId = CLIENT_ID, + redirectUri = "https://app.example.test/identity/v1/federation/tenant-1/workforce/callback", + jitProvisioningEnabled = jitProvisioningEnabled + ) + + private fun providerOrganization(config: OidcProviderConfig) = IdentityFixtures.organization( + id = config.tenantId, + slug = "tenant-one" + ) + + private fun emptyProviderStore(config: OidcProviderConfig) = InMemoryIdentityStore( + InMemoryIdentityStoreSeed(organizations = listOf(providerOrganization(config))) + ) + + private class RejectingJitLinkStore( + private val delegate: IdentityStore, + ) : IdentityStore by delegate { + var capturedProvisioning: FederationJitProvisioning? = null + + override suspend fun linkExternalIdentity( + command: LinkExternalIdentityCommand + ): StoreResult { + capturedProvisioning = command.jitProvisioning + return StoreResult.Failure( + IdentityStoreError(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + ) + } + } + + private fun request(binding: ByteArray) = OidcAuthorizationRequest(binding) + + private fun callback(start: OidcAuthorizationStart, state: String, binding: ByteArray) = + OidcCallbackRequest( + state = state, + authorizationCode = "authorization-code", + callbackBinding = binding, + callbackSecret = start.callbackSecret, + providerLease = start.providerLease + ) + + private fun discovery( + issuer: String = ISSUER, + pkceMethods: String = "[\"S256\"]", + tokenEndpoint: String = TOKEN_ENDPOINT + ): String = """{ + "issuer":"$issuer", + "authorization_endpoint":"$AUTHORIZATION_ENDPOINT", + "token_endpoint":"$tokenEndpoint", + "jwks_uri":"$JWKS_URI", + "response_types_supported":["code"], + "subject_types_supported":["public"], + "code_challenge_methods_supported":$pkceMethods, + "id_token_signing_alg_values_supported":["ES256","RS256"], + "token_endpoint_auth_methods_supported":["none"] + }""".trimIndent() + + private fun jwks(keyId: String): String { + val x = Base64Url.encode(ByteArray(32) { 1 }) + val y = Base64Url.encode(ByteArray(32) { 2 }) + return """{"keys":[{"kty":"EC","kid":"$keyId","use":"sig","key_ops":["verify"],"alg":"ES256","crv":"P-256","x":"$x","y":"$y"}]}""" + } + + private fun rsaJwks(keyId: String): String { + val modulus = ByteArray(256) { index -> if (index == 0) 0x80.toByte() else (index + 1).toByte() } + val exponent = byteArrayOf(0x01, 0x00, 0x01) + return """{"keys":[{"kty":"RSA","kid":"$keyId","use":"sig","alg":"RS256","n":"${Base64Url.encode(modulus)}","e":"${Base64Url.encode(exponent)}"}]}""" + } + + private fun idToken(nonce: String, keyId: String = "active-key", algorithm: String = "ES256"): String { + val now = IdentityFixtures.baseInstant.epochSeconds + return jwt(claimsJson(nonce, now), keyId, algorithm) + } + + private fun claimsJson( + nonce: String, + now: Long, + issuer: String = ISSUER, + audienceJson: String = "\"$CLIENT_ID\"", + authorizedParty: String? = null, + issuedAt: Long = now, + expiresAt: Long = now + 600 + ): String = buildString { + append("{\"iss\":\"").append(issuer).append("\",") + append("\"sub\":\"").append(SUBJECT.value).append("\",") + append("\"aud\":").append(audienceJson).append(',') + authorizedParty?.let { append("\"azp\":\"").append(it).append("\",") } + append("\"exp\":").append(expiresAt).append(',') + append("\"iat\":").append(issuedAt).append(',') + append("\"nonce\":\"").append(nonce).append("\",") + append("\"email\":\"user-1@example.test\",\"name\":\"OIDC User\"}") + } + + private fun jwt(claimsJson: String, keyId: String, algorithm: String): String { + val header = """{"alg":"$algorithm","kid":"$keyId","typ":"JWT"}""" + val signatureSize = if (algorithm == "RS256") 256 else 64 + return Base64Url.encode(header.encodeToByteArray()) + "." + + Base64Url.encode(claimsJson.encodeToByteArray()) + "." + + Base64Url.encode(ByteArray(signatureSize) { 7 }) + } + + private fun tokenResponse(idToken: String): String = + """{"token_type":"Bearer","id_token":"$idToken","access_token":"not-consumed"}""" + + private fun jsonResponse(body: String): IdentityHttpResponse = IdentityHttpResponse( + statusCode = 200, + headers = mapOf("Content-Type" to "application/json"), + body = body.encodeToByteArray() + ) + + private fun queryParameter(url: String, name: String): String = url.substringAfter('?') + .split('&') + .first { it.substringBefore('=') == name } + .substringAfter('=') + + private companion object { + const val ISSUER = "https://issuer.example.test" + const val CLIENT_ID = "aether-client" + const val AUTHORIZATION_ENDPOINT = "https://issuer.example.test/authorize" + const val TOKEN_ENDPOINT = "https://issuer.example.test/token" + const val JWKS_URI = "https://issuer.example.test/jwks" + val USER_ID = UserId("user-1") + val SUBJECT = ExternalSubject("provider-subject-1") + val BINDING = ByteArray(32) { (it + 1).toByte() } + } +} diff --git a/aether-auth-oidc/src/jvmTest/kotlin/codes/yousef/aether/auth/oidc/OidcRsaKeyEncodingTest.kt b/aether-auth-oidc/src/jvmTest/kotlin/codes/yousef/aether/auth/oidc/OidcRsaKeyEncodingTest.kt new file mode 100644 index 0000000..04affa2 --- /dev/null +++ b/aether-auth-oidc/src/jvmTest/kotlin/codes/yousef/aether/auth/oidc/OidcRsaKeyEncodingTest.kt @@ -0,0 +1,38 @@ +package codes.yousef.aether.auth.oidc + +import codes.yousef.aether.auth.JvmIdentityCrypto +import codes.yousef.aether.auth.RsaPublicKey +import codes.yousef.aether.auth.RsaSha256Signature +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.interfaces.RSAPublicKey +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class OidcRsaKeyEncodingTest { + @Test + fun jwkRsaComponentsProduceAUsableSubjectPublicKeyInfo() = runTest { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val publicKey = pair.public as RSAPublicKey + val modulus = publicKey.modulus.toByteArray().stripUnsignedSignByte() + val exponent = publicKey.publicExponent.toByteArray().stripUnsignedSignByte() + val message = "oidc-rs256-spki-known-path".encodeToByteArray() + val signature = Signature.getInstance("SHA256withRSA").run { + initSign(pair.private) + update(message) + sign() + } + + assertTrue( + JvmIdentityCrypto().verifyRsaSha256( + RsaPublicKey(rsaSubjectPublicKeyInfo(modulus, exponent)), + message, + RsaSha256Signature(signature) + ) + ) + } + + private fun ByteArray.stripUnsignedSignByte(): ByteArray = + if (size > 1 && first() == 0.toByte()) copyOfRange(1, size) else this +} diff --git a/aether-auth-postgresql/build.gradle.kts b/aether-auth-postgresql/build.gradle.kts new file mode 100644 index 0000000..6ffb8c1 --- /dev/null +++ b/aether-auth-postgresql/build.gradle.kts @@ -0,0 +1,42 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + testRuns["test"].executionTask.configure { + useJUnitPlatform() + // Testcontainers 1.x otherwise negotiates Docker API 1.32, rejected by current daemons. + systemProperty("api.version", "1.40") + } + } + wasmJs { nodejs() } + wasmWasi { nodejs() } + + sourceSets { + commonMain.dependencies { + api(project(":aether-auth")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(project(":aether-auth-testkit")) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + jvmMain.dependencies { + implementation(libs.vertx.pg.client) + implementation(libs.vertx.kotlin.coroutines) + // Optional in Vert.x's POM, but required by PostgreSQL's default SCRAM authentication. + implementation("com.ongres.scram:client:2.1") + } + jvmTest.dependencies { + implementation(libs.testcontainers.postgresql) + implementation(libs.logback.classic) + } + } +} diff --git a/aether-auth-postgresql/gradle.lockfile b/aether-auth-postgresql/gradle.lockfile new file mode 100644 index 0000000..3caac40 --- /dev/null +++ b/aether-auth-postgresql/gradle.lockfile @@ -0,0 +1,124 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +ch.qos.logback:logback-classic:1.5.12=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +ch.qos.logback:logback-core:1.5.12=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.16.1=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.16.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.16.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.github.docker-java:docker-java-api:3.4.0=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.4.0=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.github.docker-java:docker-java-transport:3.4.0=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.ongres.scram:client:2.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.ongres.scram:common:2.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.ongres.stringprep:saslprep:1.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +com.ongres.stringprep:stringprep:1.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.netty:netty-buffer:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-common:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-handler:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.netty:netty-transport:4.1.115.Final=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.11=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.vertx:vertx-pg-client:4.5.11=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +io.vertx:vertx-sql-client:4.5.11=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +junit:junit:4.13.2=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +net.java.dev.jna:jna:5.13.0=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.apache.commons:commons-compress:1.24.0=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jvmTestCompileClasspath +org.hamcrest:hamcrest-core:1.3=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-wasi:2.3.21=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit5:2.3.21=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-wasi:2.3.21=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.26.1=wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.30.0-beta=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-wasi:1.10.2=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-wasi:1.10.2=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-wasi:0.7.1=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:17.0.0=allTestSourceSetsCompileDependenciesMetadata,jvmTestResolvableDependenciesMetadata +org.jetbrains:annotations:23.0.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.10.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.10.1=jvmTestRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.junit.platform:junit-platform-engine:1.10.1=jvmTestRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.10.1=jvmTestRuntimeClasspath +org.junit:junit-bom:5.10.1=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.15=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata +org.slf4j:slf4j-api:2.0.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.testcontainers:database-commons:1.20.4=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.testcontainers:jdbc:1.20.4=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.testcontainers:postgresql:1.20.4=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +org.testcontainers:testcontainers:1.20.4=allTestSourceSetsCompileDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,wasmWasiMainImplementationDependenciesMetadata,wasmWasiTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlFailures.kt b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlFailures.kt new file mode 100644 index 0000000..f4054f7 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlFailures.kt @@ -0,0 +1,59 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityStoreError +import codes.yousef.aether.auth.IdentityStoreErrorCode + +internal class PostgresqlStoreException( + val safeError: IdentityStoreError +) : RuntimeException("PostgreSQL identity operation failed") { + override fun toString(): String = + "PostgresqlStoreException(code=${safeError.code}, retryable=${safeError.retryable})" +} + +internal object PostgresqlFailureMapper { + fun fromProviderCode(providerCode: String?, httpStatus: Int? = null): IdentityStoreError { + val normalized = providerCode?.trim()?.uppercase() + return when { + normalized == "A0001" -> failure(IdentityStoreErrorCode.INTERNAL) + normalized == "A0002" -> failure(IdentityStoreErrorCode.VERSION_CONFLICT, retryable = true) + normalized == "A0003" -> failure(IdentityStoreErrorCode.INVALID_TRANSITION) + normalized == "A0004" -> failure(IdentityStoreErrorCode.LAST_OWNER) + normalized == "A0005" -> failure(IdentityStoreErrorCode.REPLAY_DETECTED) + normalized == "A0006" -> failure(IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT) + normalized == "A0007" -> failure(IdentityStoreErrorCode.CHALLENGE_NOT_PENDING) + normalized == "A0008" -> failure(IdentityStoreErrorCode.CHALLENGE_EXPIRED) + normalized == "A0009" -> failure(IdentityStoreErrorCode.SESSION_NOT_ACTIVE) + normalized == "A0010" -> failure(IdentityStoreErrorCode.SESSION_EXPIRED) + normalized == "A0011" -> failure(IdentityStoreErrorCode.RECOVERY_CODE_NOT_ACTIVE) + normalized == "A0012" -> failure(IdentityStoreErrorCode.NOT_FOUND) + normalized == "A0013" -> failure(IdentityStoreErrorCode.ALREADY_EXISTS) + normalized == "A0014" -> failure(IdentityStoreErrorCode.INTERNAL) + normalized == "A0015" -> failure(IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED) + normalized == "23505" -> failure(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + normalized == "23503" || normalized == "23514" || normalized == "22P02" -> + failure(IdentityStoreErrorCode.INVALID_TRANSITION) + normalized == "40001" || normalized == "40P01" -> + failure(IdentityStoreErrorCode.VERSION_CONFLICT, retryable = true) + normalized?.startsWith("08") == true || normalized == "53300" || normalized == "57P01" || + normalized == "57P02" || normalized == "57P03" || normalized == "PGRST000" || + normalized == "PGRST001" || normalized == "PGRST002" -> + failure(IdentityStoreErrorCode.UNAVAILABLE, retryable = true) + normalized == "PGRST116" -> failure(IdentityStoreErrorCode.NOT_FOUND) + normalized == "42501" || normalized == "42P01" || normalized == "42883" || normalized == "PGRST202" -> + failure(IdentityStoreErrorCode.INTERNAL) + httpStatus == 404 -> failure(IdentityStoreErrorCode.INTERNAL) + httpStatus == 409 -> failure(IdentityStoreErrorCode.VERSION_CONFLICT, retryable = true) + httpStatus == 429 || (httpStatus != null && httpStatus >= 500) -> + failure(IdentityStoreErrorCode.UNAVAILABLE, retryable = true) + httpStatus != null && httpStatus in 400..499 -> failure(IdentityStoreErrorCode.INTERNAL) + else -> failure(IdentityStoreErrorCode.INTERNAL) + } + } + + fun unavailable(): IdentityStoreError = failure(IdentityStoreErrorCode.UNAVAILABLE, retryable = true) + + fun internal(): IdentityStoreError = failure(IdentityStoreErrorCode.INTERNAL) + + private fun failure(code: IdentityStoreErrorCode, retryable: Boolean = false): IdentityStoreError = + IdentityStoreError(code, retryable) +} diff --git a/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityConfig.kt b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityConfig.kt new file mode 100644 index 0000000..84cef35 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityConfig.kt @@ -0,0 +1,78 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityConfig +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.SecretReference +import kotlinx.serialization.Serializable + +/** + * PostgreSQL adapter configuration shared by the direct JVM and PostgREST transports. + * The schema is fixed by the shipped migrations; [namespace] separates deployment data and is + * verified against the database environment marker before the store can be used. + */ +@Serializable +data class PostgresqlIdentityConfig( + val environment: IdentityEnvironment, + val namespace: String, + val schema: String = DEFAULT_SCHEMA, + val postgrestBaseUrl: String? = null, + val postgrestAuthorizationSecret: SecretReference? = null, + val maximumRequestBytes: Int = DEFAULT_MAXIMUM_REQUEST_BYTES, + val maximumResponseBytes: Int = DEFAULT_MAXIMUM_RESPONSE_BYTES +) { + init { + require(schema == DEFAULT_SCHEMA) { "PostgreSQL identity schema must match the shipped migrations" } + require(NAMESPACE.matches(namespace)) { "Invalid PostgreSQL identity namespace" } + require(environment.wireName in namespace) { + "PostgreSQL identity namespace must contain the environment name" + } + require(maximumRequestBytes in 1_024..MAXIMUM_WIRE_BYTES) { "Invalid maximum PostgreSQL RPC request size" } + require(maximumResponseBytes in 1_024..MAXIMUM_WIRE_BYTES) { "Invalid maximum PostgreSQL RPC response size" } + + postgrestBaseUrl?.let(::requireValidPostgrestBaseUrl) + require(postgrestBaseUrl != null || postgrestAuthorizationSecret == null) { + "PostgREST authorization cannot be configured without a PostgREST base URL" + } + postgrestAuthorizationSecret?.let { + require(it.environment == environment) { "PostgREST authorization secret belongs to another environment" } + } + if (postgrestBaseUrl != null && environment in setOf(IdentityEnvironment.STAGING, IdentityEnvironment.PRODUCTION)) { + require(postgrestAuthorizationSecret != null) { "Staging and production PostgREST require authorization" } + } + } + + val normalizedPostgrestBaseUrl: String? + get() = postgrestBaseUrl?.trimEnd('/') + + override fun toString(): String = + "PostgresqlIdentityConfig(environment=${environment.wireName}, namespace=$namespace, schema=$schema, " + + "postgrestBaseUrl=${if (postgrestBaseUrl == null) "none" else ""}, " + + "postgrestAuthorization=)" + + companion object { + const val DEFAULT_SCHEMA: String = "aether_identity" + const val DEFAULT_MAXIMUM_REQUEST_BYTES: Int = 2 * 1_024 * 1_024 + const val DEFAULT_MAXIMUM_RESPONSE_BYTES: Int = 4 * 1_024 * 1_024 + private const val MAXIMUM_WIRE_BYTES: Int = 16 * 1_024 * 1_024 + private val NAMESPACE = Regex("[a-z][a-z0-9_-]{2,63}") + + fun fromIdentityConfig( + identity: IdentityConfig, + postgrestBaseUrl: String? = null, + postgrestAuthorizationSecret: SecretReference? = null + ): PostgresqlIdentityConfig = PostgresqlIdentityConfig( + environment = identity.environment, + namespace = identity.storageNamespace, + postgrestBaseUrl = postgrestBaseUrl, + postgrestAuthorizationSecret = postgrestAuthorizationSecret + ) + } +} + +private fun requireValidPostgrestBaseUrl(value: String) { + require(value == value.trim() && value.isNotEmpty()) { "Invalid PostgREST base URL" } + require('?' !in value && '#' !in value && '@' !in value) { "PostgREST base URL must not contain query, fragment, or user info" } + IdentityHttpRequest(IdentityHttpMethod.GET, value) +} diff --git a/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStore.kt b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStore.kt new file mode 100644 index 0000000..e523e36 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStore.kt @@ -0,0 +1,505 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Storage-neutral contract implementation backed exclusively by fixed PostgreSQL RPC functions. + * Call [initialize] during application startup; operations fail closed until the database marker + * confirms the configured environment and namespace. + */ +class PostgresqlIdentityStore( + private val config: PostgresqlIdentityConfig, + private val transport: PostgresqlRpcTransport, + private val json: Json = defaultPostgresqlJson() +) : IdentityStore { + private val initializationMutex = Mutex() + private var initialized = false + + suspend fun initialize(): StoreResult { + initializationMutex.lock() + return try { + if (initialized) return StoreResult.Success(Unit) + when ( + val result = invokeRaw( + operation = PostgresqlRpcOperation.ASSERT_ENVIRONMENT, + payload = JsonObject(emptyMap()) + ) + ) { + is StoreResult.Failure -> result + is StoreResult.Success -> { + val value = result.value + if (!value.verified || value.environment != config.environment || value.namespace != config.namespace) { + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } else { + initialized = true + StoreResult.Success(Unit) + } + } + } + } finally { + initializationMutex.unlock() + } + } + + override suspend fun findUser(id: UserId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_USER, IdPayload(id.value)) + + override suspend fun findUserByEmail(email: EmailAddress): StoreResult = + invoke(PostgresqlRpcOperation.FIND_USER_BY_EMAIL, EmailPayload(email)) + + override suspend fun findCredential(id: CredentialId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_CREDENTIAL, IdPayload(id.value)) + + override suspend fun findCredentialByWebAuthnId(id: WebAuthnCredentialId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_CREDENTIAL_BY_WEB_AUTHN_ID, WebAuthnIdPayload(id)) + + override suspend fun listCredentialsForUser(userId: UserId): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_CREDENTIALS_FOR_USER, UserIdPayload(userId)) + + override suspend fun findSession(id: SessionId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_SESSION, IdPayload(id.value)) + + override suspend fun listSessionsForUser(userId: UserId): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_SESSIONS_FOR_USER, UserIdPayload(userId)) + + override suspend fun findOrganization(id: OrganizationId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_ORGANIZATION, IdPayload(id.value)) + + override suspend fun findOrganizationBySlug(slug: String): StoreResult = + invoke(PostgresqlRpcOperation.FIND_ORGANIZATION_BY_SLUG, SlugPayload(slug)) + + override suspend fun listOrganizationsForUser(userId: UserId): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_ORGANIZATIONS_FOR_USER, UserIdPayload(userId)) + + override suspend fun findMembership(id: MembershipId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_MEMBERSHIP, IdPayload(id.value)) + + override suspend fun findMembershipForUser( + userId: UserId, + organizationId: OrganizationId + ): StoreResult = invoke( + PostgresqlRpcOperation.FIND_MEMBERSHIP_FOR_USER, + UserOrganizationPayload(userId, organizationId) + ) + + override suspend fun listMembershipsForOrganization( + organizationId: OrganizationId + ): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_MEMBERSHIPS_FOR_ORGANIZATION, OrganizationIdPayload(organizationId)) + + override suspend fun findInvitation(id: InvitationId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_INVITATION, IdPayload(id.value)) + + override suspend fun findInvitationByTokenDigest(digest: SecretDigest): StoreResult = + invoke(PostgresqlRpcOperation.FIND_INVITATION_BY_TOKEN_DIGEST, DigestPayload(digest)) + + override suspend fun listInvitationsForOrganization( + organizationId: OrganizationId + ): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_INVITATIONS_FOR_ORGANIZATION, OrganizationIdPayload(organizationId)) + + override suspend fun findServiceIdentity(id: ServiceIdentityId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_SERVICE_IDENTITY, IdPayload(id.value)) + + override suspend fun listServiceIdentitiesForOrganization( + organizationId: OrganizationId + ): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_SERVICE_IDENTITIES_FOR_ORGANIZATION, OrganizationIdPayload(organizationId)) + + override suspend fun findServiceCredentialByPrefix(publicPrefix: String): StoreResult = + invoke(PostgresqlRpcOperation.FIND_SERVICE_CREDENTIAL_BY_PREFIX, PublicPrefixPayload(publicPrefix)) + + override suspend fun listServiceCredentialsForIdentity( + serviceIdentityId: ServiceIdentityId + ): StoreResult> = + invoke( + PostgresqlRpcOperation.LIST_SERVICE_CREDENTIALS_FOR_IDENTITY, + ServiceIdentityIdPayload(serviceIdentityId) + ) + + override suspend fun findExternalIdentity( + provider: String, + subject: ExternalSubject + ): StoreResult = invoke( + PostgresqlRpcOperation.FIND_EXTERNAL_IDENTITY, + ExternalIdentityLookupPayload(provider, subject) + ) + + override suspend fun findFederationProviderControl( + organizationId: OrganizationId, + providerId: String + ): StoreResult = invoke( + PostgresqlRpcOperation.FIND_FEDERATION_PROVIDER_CONTROL, + FederationProviderLookupPayload(organizationId, providerId) + ) + + override suspend fun findFederationProviderControlByStorageKey( + storageKey: String + ): StoreResult = invoke( + PostgresqlRpcOperation.FIND_FEDERATION_PROVIDER_CONTROL_BY_STORAGE_KEY, + StorageKeyPayload(storageKey) + ) + + override suspend fun findScimGroup( + provider: String, + organizationId: OrganizationId, + id: String + ): StoreResult = invoke( + PostgresqlRpcOperation.FIND_SCIM_GROUP, + ScimGroupLookupPayload(provider, organizationId, id) + ) + + override suspend fun findChallenge(id: ChallengeId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_CHALLENGE, IdPayload(id.value)) + + override suspend fun findRecoveryCodeBySelector(publicSelector: String): StoreResult = + invoke(PostgresqlRpcOperation.FIND_RECOVERY_CODE_BY_SELECTOR, PublicSelectorPayload(publicSelector)) + + override suspend fun listRecoveryCodesForUser(userId: UserId): StoreResult> = + invoke(PostgresqlRpcOperation.LIST_RECOVERY_CODES_FOR_USER, UserIdPayload(userId)) + + override suspend fun findDeviceGrant(id: DeviceGrantId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_DEVICE_GRANT, IdPayload(id.value)) + + override suspend fun findDeviceGrantByDeviceCodeDigest(digest: SecretDigest): StoreResult = + invoke(PostgresqlRpcOperation.FIND_DEVICE_GRANT_BY_DEVICE_CODE_DIGEST, DigestPayload(digest)) + + override suspend fun findDeviceGrantByUserCodeDigest(digest: SecretDigest): StoreResult = + invoke(PostgresqlRpcOperation.FIND_DEVICE_GRANT_BY_USER_CODE_DIGEST, DigestPayload(digest)) + + override suspend fun findDeviceTokenFamily(id: DeviceTokenFamilyId): StoreResult = + invoke(PostgresqlRpcOperation.FIND_DEVICE_TOKEN_FAMILY, IdPayload(id.value)) + + override suspend fun findDeviceAccessTokenBySelector( + publicSelector: String + ): StoreResult = + invoke(PostgresqlRpcOperation.FIND_DEVICE_ACCESS_TOKEN_BY_SELECTOR, PublicSelectorPayload(publicSelector)) + + override suspend fun findDeviceRefreshTokenBySelector( + publicSelector: String + ): StoreResult = + invoke(PostgresqlRpcOperation.FIND_DEVICE_REFRESH_TOKEN_BY_SELECTOR, PublicSelectorPayload(publicSelector)) + + override suspend fun listAuditEventsForOrganization( + request: OrganizationAuditEventPageRequest + ): StoreResult = + invoke(PostgresqlRpcOperation.LIST_AUDIT_EVENTS_FOR_ORGANIZATION, request) + + override suspend fun purgeAuditEvents( + command: PurgeAuditEventsCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.PURGE_AUDIT_EVENTS, command) + + override suspend fun createChallenge(command: CreateChallengeCommand): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_CHALLENGE, command) + + override suspend fun consumeChallenge(command: ConsumeChallengeCommand): StoreResult = + invoke(PostgresqlRpcOperation.CONSUME_CHALLENGE, command) + + override suspend fun appendAuditEvent(event: AuditEvent): StoreResult = + invoke(PostgresqlRpcOperation.APPEND_AUDIT_EVENT, AuditEventPayload(event)) + + override suspend fun bootstrapIdentity( + command: BootstrapIdentityCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.BOOTSTRAP_IDENTITY, command) + + override suspend fun completeCredentialRegistration( + command: CompleteCredentialRegistrationCommand + ): StoreResult> = + invoke(PostgresqlRpcOperation.COMPLETE_CREDENTIAL_REGISTRATION, command) + + override suspend fun completeCredentialAuthentication( + command: CompleteCredentialAuthenticationCommand + ): StoreResult> = + invoke(PostgresqlRpcOperation.COMPLETE_CREDENTIAL_AUTHENTICATION, command) + + override suspend fun quarantineCredentialAuthentication( + command: QuarantineCredentialAuthenticationCommand + ): StoreResult> = + invoke(PostgresqlRpcOperation.QUARANTINE_CREDENTIAL_AUTHENTICATION, command) + + override suspend fun mutateCredential(command: MutateCredentialCommand): StoreResult = + invoke(PostgresqlRpcOperation.MUTATE_CREDENTIAL, command) + + override suspend fun createSession(command: CreateSessionCommand): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_SESSION, command) + + override suspend fun touchIdentitySession(command: TouchIdentitySessionCommand): StoreResult = + invoke(PostgresqlRpcOperation.TOUCH_IDENTITY_SESSION, command) + + override suspend fun rotateSession(command: RotateSessionCommand): StoreResult = + invoke(PostgresqlRpcOperation.ROTATE_SESSION, command) + + override suspend fun revokeSession(command: RevokeSessionCommand): StoreResult = + invoke(PostgresqlRpcOperation.REVOKE_SESSION, command) + + override suspend fun revokeUserSessions( + command: RevokeUserSessionsCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.REVOKE_USER_SESSIONS, command) + + override suspend fun acquireFederationProviderLease( + command: AcquireFederationProviderLeaseCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.ACQUIRE_FEDERATION_PROVIDER_LEASE, command) + + override suspend fun validateFederationProviderLease( + lease: FederationProviderLease + ): StoreResult = + invoke(PostgresqlRpcOperation.VALIDATE_FEDERATION_PROVIDER_LEASE, lease) + + override suspend fun compareAndSetFederationProviderState( + command: CompareAndSetFederationProviderStateCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.COMPARE_AND_SET_FEDERATION_PROVIDER_STATE, command) + + override suspend fun replaceRecoveryCodes( + command: ReplaceRecoveryCodesCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.REPLACE_RECOVERY_CODES, command) + + override suspend fun consumeRecoveryCode( + command: ConsumeRecoveryCodeCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.CONSUME_RECOVERY_CODE, command) + + override suspend fun activateAdministrativeRecoveryTicket( + command: ActivateAdministrativeRecoveryTicketCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.ACTIVATE_ADMINISTRATIVE_RECOVERY_TICKET, command) + + override suspend fun redeemAdministrativeRecoveryTicket( + command: RedeemAdministrativeRecoveryTicketCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.REDEEM_ADMINISTRATIVE_RECOVERY_TICKET, command) + + override suspend fun completeRecoveryEnrollment( + command: CompleteRecoveryEnrollmentCommand + ): StoreResult> = + invoke(PostgresqlRpcOperation.COMPLETE_RECOVERY_ENROLLMENT, command) + + override suspend fun createOrganization( + command: CreateOrganizationCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_ORGANIZATION, command) + + override suspend fun mutateOrganization(command: MutateOrganizationCommand): StoreResult = + invoke(PostgresqlRpcOperation.MUTATE_ORGANIZATION, command) + + override suspend fun createInvitation(command: CreateInvitationCommand): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_INVITATION, command) + + override suspend fun mutateInvitation(command: MutateInvitationCommand): StoreResult = + invoke(PostgresqlRpcOperation.MUTATE_INVITATION, command) + + override suspend fun enrollInvitation( + command: EnrollInvitationCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.ENROLL_INVITATION, command) + + override suspend fun createMembership(command: CreateMembershipCommand): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_MEMBERSHIP, command) + + override suspend fun mutateMembership(command: MutateMembershipCommand): StoreResult = + invoke(PostgresqlRpcOperation.MUTATE_MEMBERSHIP, command) + + override suspend fun createServiceIdentity( + command: CreateServiceIdentityCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_SERVICE_IDENTITY, command) + + override suspend fun mutateServiceIdentity( + command: MutateServiceIdentityCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.MUTATE_SERVICE_IDENTITY, command) + + override suspend fun createServiceCredential( + command: CreateServiceCredentialCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.CREATE_SERVICE_CREDENTIAL, command) + + override suspend fun revokeServiceCredential( + command: RevokeServiceCredentialCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.REVOKE_SERVICE_CREDENTIAL, command) + + override suspend fun compareAndSetDeviceGrant( + command: CompareAndSetDeviceGrantCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.COMPARE_AND_SET_DEVICE_GRANT, command) + + override suspend fun exchangeDeviceGrant( + command: ExchangeDeviceGrantCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.EXCHANGE_DEVICE_GRANT, command) + + override suspend fun rotateDeviceRefreshToken( + command: RotateDeviceRefreshTokenCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.ROTATE_DEVICE_REFRESH_TOKEN, command) + + override suspend fun revokeDeviceTokenFamily( + command: RevokeDeviceTokenFamilyCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.REVOKE_DEVICE_TOKEN_FAMILY, command) + + override suspend fun rotateServiceCredential( + command: RotateServiceCredentialCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.ROTATE_SERVICE_CREDENTIAL, command) + + override suspend fun linkExternalIdentity( + command: LinkExternalIdentityCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.LINK_EXTERNAL_IDENTITY, command) + + override suspend fun recordExternalIdentityReplay( + command: RecordExternalIdentityReplayCommand + ): StoreResult = + invoke(PostgresqlRpcOperation.RECORD_EXTERNAL_IDENTITY_REPLAY, command) + + override suspend fun applyScimMutation(command: ApplyScimMutationCommand): StoreResult = + invoke(PostgresqlRpcOperation.APPLY_SCIM_MUTATION, command) + + override suspend fun applyScimBatch(command: ApplyScimBatchCommand): StoreResult = + invoke(PostgresqlRpcOperation.APPLY_SCIM_BATCH, command) + + private suspend inline fun invoke( + operation: PostgresqlRpcOperation, + payload: Request + ): StoreResult { + initializationMutex.lock() + val ready = try { + initialized + } finally { + initializationMutex.unlock() + } + if (!ready) return StoreResult.Failure(PostgresqlFailureMapper.unavailable()) + return invokeRaw(operation, payload) + } + + private suspend inline fun invokeRaw( + operation: PostgresqlRpcOperation, + payload: JsonObject + ): StoreResult = invokeRawElement(operation, payload) + + private suspend inline fun invokeRaw( + operation: PostgresqlRpcOperation, + payload: Request + ): StoreResult = try { + invokeRawElement(operation, json.encodeToJsonElement(payload)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: SerializationException) { + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } + + private suspend inline fun invokeRawElement( + operation: PostgresqlRpcOperation, + payload: kotlinx.serialization.json.JsonElement + ): StoreResult = try { + val response = transport.execute( + PostgresqlRpcRequestEnvelope( + operation = operation.wireName, + environment = config.environment, + namespace = config.namespace, + payload = payload + ) + ) + when (response.outcome) { + PostgresqlRpcOutcome.FAILURE -> StoreResult.Failure(requireNotNull(response.error)) + PostgresqlRpcOutcome.SUCCESS -> StoreResult.Success( + json.decodeFromJsonElement(response.result) + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: PostgresqlStoreException) { + StoreResult.Failure(failure.safeError) + } catch (_: SerializationException) { + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } catch (_: Throwable) { + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } +} + +@Serializable +private data class EnvironmentAssertionResult( + val verified: Boolean, + val environment: IdentityEnvironment, + val namespace: String +) + +@Serializable +private data class IdPayload(val id: String) + +@Serializable +private data class UserIdPayload(val userId: UserId) + +@Serializable +private data class OrganizationIdPayload(val organizationId: OrganizationId) + +@Serializable +private data class ServiceIdentityIdPayload(val serviceIdentityId: ServiceIdentityId) + +@Serializable +private data class SlugPayload(val slug: String) + +@Serializable +private data class WebAuthnIdPayload(val webAuthnId: WebAuthnCredentialId) + +@Serializable +private data class EmailPayload(val email: EmailAddress) + +@Serializable +private data class UserOrganizationPayload( + val userId: UserId, + val organizationId: OrganizationId +) + +@Serializable +private data class FederationProviderLookupPayload( + val organizationId: OrganizationId, + val providerId: String +) + +@Serializable +private data class StorageKeyPayload(val storageKey: String) + +@Serializable +private data class PublicPrefixPayload(val publicPrefix: String) + +@Serializable +private data class PublicSelectorPayload(val publicSelector: String) + +@Serializable +private data class DigestPayload(val digest: SecretDigest) + +@Serializable +private data class AuditEventPayload(val event: AuditEvent) + +@Serializable +private data class ExternalIdentityLookupPayload( + val provider: String, + val subject: ExternalSubject +) + +@Serializable +private data class ScimGroupLookupPayload( + val provider: String, + val organizationId: OrganizationId, + val id: String +) diff --git a/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcProtocol.kt b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcProtocol.kt new file mode 100644 index 0000000..f8675d2 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcProtocol.kt @@ -0,0 +1,150 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.IdentityStoreError +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull + +/** Every store method has a fixed, versioned database function; callers cannot inject RPC names. */ +enum class PostgresqlRpcOperation( + val wireName: String, + val functionName: String = "v1_$wireName" +) { + ASSERT_ENVIRONMENT("assert_environment"), + FIND_USER("find_user"), + FIND_USER_BY_EMAIL("find_user_by_email"), + FIND_CREDENTIAL("find_credential"), + FIND_CREDENTIAL_BY_WEB_AUTHN_ID("find_credential_by_web_authn_id"), + LIST_CREDENTIALS_FOR_USER("list_credentials_for_user"), + FIND_SESSION("find_session"), + LIST_SESSIONS_FOR_USER("list_sessions_for_user"), + FIND_ORGANIZATION("find_organization"), + FIND_ORGANIZATION_BY_SLUG("find_organization_by_slug"), + LIST_ORGANIZATIONS_FOR_USER("list_organizations_for_user"), + FIND_MEMBERSHIP("find_membership"), + FIND_MEMBERSHIP_FOR_USER("find_membership_for_user"), + LIST_MEMBERSHIPS_FOR_ORGANIZATION("list_memberships_for_organization"), + FIND_INVITATION("find_invitation"), + FIND_INVITATION_BY_TOKEN_DIGEST("find_invitation_by_token_digest"), + LIST_INVITATIONS_FOR_ORGANIZATION("list_invitations_for_organization"), + FIND_SERVICE_IDENTITY("find_service_identity"), + LIST_SERVICE_IDENTITIES_FOR_ORGANIZATION("list_service_identities_for_organization"), + FIND_SERVICE_CREDENTIAL_BY_PREFIX("find_service_credential_by_prefix"), + LIST_SERVICE_CREDENTIALS_FOR_IDENTITY("list_service_credentials_for_identity"), + FIND_EXTERNAL_IDENTITY("find_external_identity"), + FIND_FEDERATION_PROVIDER_CONTROL("find_federation_provider_control"), + FIND_FEDERATION_PROVIDER_CONTROL_BY_STORAGE_KEY("find_federation_provider_control_by_storage_key"), + FIND_SCIM_GROUP("find_scim_group"), + FIND_CHALLENGE("find_challenge"), + FIND_RECOVERY_CODE_BY_SELECTOR("find_recovery_code_by_selector"), + LIST_RECOVERY_CODES_FOR_USER("list_recovery_codes_for_user"), + FIND_DEVICE_GRANT("find_device_grant"), + FIND_DEVICE_GRANT_BY_DEVICE_CODE_DIGEST("find_device_grant_by_device_code_digest"), + FIND_DEVICE_GRANT_BY_USER_CODE_DIGEST("find_device_grant_by_user_code_digest"), + FIND_DEVICE_TOKEN_FAMILY("find_device_token_family"), + FIND_DEVICE_ACCESS_TOKEN_BY_SELECTOR("find_device_access_token_by_selector"), + FIND_DEVICE_REFRESH_TOKEN_BY_SELECTOR("find_device_refresh_token_by_selector"), + LIST_AUDIT_EVENTS_FOR_ORGANIZATION("list_audit_events_for_organization"), + PURGE_AUDIT_EVENTS("purge_audit_events"), + CREATE_CHALLENGE("create_challenge"), + CONSUME_CHALLENGE("consume_challenge"), + APPEND_AUDIT_EVENT("append_audit_event"), + BOOTSTRAP_IDENTITY("bootstrap_identity"), + COMPLETE_CREDENTIAL_REGISTRATION("complete_credential_registration"), + COMPLETE_CREDENTIAL_AUTHENTICATION("complete_credential_authentication"), + QUARANTINE_CREDENTIAL_AUTHENTICATION("quarantine_credential_authentication"), + MUTATE_CREDENTIAL("mutate_credential"), + CREATE_SESSION("create_session"), + TOUCH_IDENTITY_SESSION("touch_identity_session"), + ROTATE_SESSION("rotate_session"), + REVOKE_SESSION("revoke_session"), + REVOKE_USER_SESSIONS("revoke_user_sessions"), + ACQUIRE_FEDERATION_PROVIDER_LEASE("acquire_federation_provider_lease"), + VALIDATE_FEDERATION_PROVIDER_LEASE("validate_federation_provider_lease"), + COMPARE_AND_SET_FEDERATION_PROVIDER_STATE("compare_and_set_federation_provider_state"), + REPLACE_RECOVERY_CODES("replace_recovery_codes"), + CONSUME_RECOVERY_CODE("consume_recovery_code"), + ACTIVATE_ADMINISTRATIVE_RECOVERY_TICKET("activate_administrative_recovery_ticket"), + REDEEM_ADMINISTRATIVE_RECOVERY_TICKET("redeem_administrative_recovery_ticket"), + COMPLETE_RECOVERY_ENROLLMENT("complete_recovery_enrollment"), + CREATE_ORGANIZATION("create_organization"), + MUTATE_ORGANIZATION("mutate_organization"), + CREATE_INVITATION("create_invitation"), + MUTATE_INVITATION("mutate_invitation"), + ENROLL_INVITATION("enroll_invitation"), + CREATE_MEMBERSHIP("create_membership"), + MUTATE_MEMBERSHIP("mutate_membership"), + CREATE_SERVICE_IDENTITY("create_service_identity"), + MUTATE_SERVICE_IDENTITY("mutate_service_identity"), + CREATE_SERVICE_CREDENTIAL("create_service_credential"), + REVOKE_SERVICE_CREDENTIAL("revoke_service_credential"), + COMPARE_AND_SET_DEVICE_GRANT("compare_and_set_device_grant"), + EXCHANGE_DEVICE_GRANT("exchange_device_grant"), + ROTATE_DEVICE_REFRESH_TOKEN("rotate_device_refresh_token"), + REVOKE_DEVICE_TOKEN_FAMILY("revoke_device_token_family"), + ROTATE_SERVICE_CREDENTIAL("rotate_service_credential"), + LINK_EXTERNAL_IDENTITY("link_external_identity"), + RECORD_EXTERNAL_IDENTITY_REPLAY("record_external_identity_replay"), + APPLY_SCIM_MUTATION("apply_scim_mutation"), + APPLY_SCIM_BATCH("apply_scim_batch") +} + +@Serializable +data class PostgresqlRpcRequestEnvelope( + val protocolVersion: Int = CURRENT_PROTOCOL_VERSION, + val operation: String, + val environment: IdentityEnvironment, + val namespace: String, + val requestId: String? = null, + val payload: JsonElement +) { + init { + require(protocolVersion == CURRENT_PROTOCOL_VERSION) { "Unsupported PostgreSQL identity RPC protocol" } + require(PostgresqlRpcOperation.entries.any { it.wireName == operation }) { "Unknown PostgreSQL identity RPC operation" } + require(namespace.isNotBlank() && namespace.length <= 63) { "Invalid PostgreSQL identity namespace" } + require(requestId == null || (requestId.isNotBlank() && requestId.length <= 255)) { "Invalid PostgreSQL RPC request ID" } + } +} + +@Serializable +enum class PostgresqlRpcOutcome { + @SerialName("success") SUCCESS, + @SerialName("failure") FAILURE +} + +@Serializable +data class PostgresqlRpcResponseEnvelope( + val protocolVersion: Int = CURRENT_PROTOCOL_VERSION, + val operation: String, + val outcome: PostgresqlRpcOutcome, + val result: JsonElement = JsonNull, + val error: IdentityStoreError? = null +) { + init { + require(protocolVersion == CURRENT_PROTOCOL_VERSION) { "Unsupported PostgreSQL identity RPC protocol" } + require(PostgresqlRpcOperation.entries.any { it.wireName == operation }) { "Unknown PostgreSQL identity RPC operation" } + when (outcome) { + PostgresqlRpcOutcome.SUCCESS -> require(error == null) { "Successful RPC responses must not contain an error" } + PostgresqlRpcOutcome.FAILURE -> require(result == JsonNull && error != null) { + "Failed RPC responses require only a safe store error" + } + } + } +} + +@Serializable +internal data class PostgrestFunctionRequest( + @SerialName("p_request") val pRequest: PostgresqlRpcRequestEnvelope +) + +@Serializable +internal data class PostgrestErrorDocument( + val code: String? = null, + val message: String? = null, + val details: String? = null, + val hint: String? = null +) + +internal const val CURRENT_PROTOCOL_VERSION: Int = 1 diff --git a/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcTransport.kt b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcTransport.kt new file mode 100644 index 0000000..26ef5e1 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcTransport.kt @@ -0,0 +1,130 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.IdentityRuntime +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +fun interface PostgresqlRpcTransport { + suspend fun execute(request: PostgresqlRpcRequestEnvelope): PostgresqlRpcResponseEnvelope +} + +/** All-target PostgREST transport backed by the application's injected identity HTTP capability. */ +class PostgrestPostgresqlRpcTransport( + private val config: PostgresqlIdentityConfig, + private val runtime: IdentityRuntime, + private val json: Json = defaultPostgresqlJson() +) : PostgresqlRpcTransport { + init { + require(config.normalizedPostgrestBaseUrl != null) { "PostgREST transport requires a base URL" } + } + + override suspend fun execute(request: PostgresqlRpcRequestEnvelope): PostgresqlRpcResponseEnvelope { + val operation = operationFor(request) + val encoded = json.encodeToString(PostgrestFunctionRequest(request)).encodeToByteArray() + if (encoded.size > config.maximumRequestBytes) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + + val headers = mutableMapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + "Accept-Profile" to config.schema, + "Content-Profile" to config.schema + ) + config.postgrestAuthorizationSecret?.let { reference -> + headers["Authorization"] = try { + runtime.secrets.resolve(reference).useBytes(::bearerAuthorizationValue) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: PostgresqlStoreException) { + throw failure + } catch (_: Throwable) { + throw PostgresqlStoreException(PostgresqlFailureMapper.unavailable()) + } + } + + val response = try { + runtime.http.execute( + IdentityHttpRequest( + method = IdentityHttpMethod.POST, + url = "${config.normalizedPostgrestBaseUrl}/rpc/${operation.functionName}", + headers = headers, + body = encoded + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + throw PostgresqlStoreException(PostgresqlFailureMapper.unavailable()) + } finally { + headers.remove("Authorization") + } + + val body = response.bodyBytes() + if (body.size > config.maximumResponseBytes) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + if (response.statusCode !in 200..299) { + val providerCode = runCatching { + json.decodeFromString(body.decodeToString()).code + }.getOrNull() + throw PostgresqlStoreException( + PostgresqlFailureMapper.fromProviderCode(providerCode, response.statusCode) + ) + } + + val decoded = try { + json.decodeFromString(body.decodeToString()) + } catch (_: SerializationException) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + validateResponse(request, decoded) + return decoded + } +} + +internal fun operationFor(request: PostgresqlRpcRequestEnvelope): PostgresqlRpcOperation = + PostgresqlRpcOperation.entries.singleOrNull { it.wireName == request.operation } + ?: throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + +internal fun validateResponse( + request: PostgresqlRpcRequestEnvelope, + response: PostgresqlRpcResponseEnvelope +) { + if (response.protocolVersion != request.protocolVersion || response.operation != request.operation) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } +} + +internal fun defaultPostgresqlJson(): Json = Json { + encodeDefaults = true + explicitNulls = true + ignoreUnknownKeys = false + isLenient = false + allowSpecialFloatingPointValues = false + allowStructuredMapKeys = false +} + +private fun bearerAuthorizationValue(secret: ByteArray): String { + if (secret.isEmpty() || secret.size > MAXIMUM_BEARER_TOKEN_BYTES || secret.any { !it.isBearerTokenByte() }) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + return "Bearer ${secret.decodeToString()}" +} + +private fun Byte.isBearerTokenByte(): Boolean { + val character = toInt() and 0xff + return character in 'A'.code..'Z'.code || + character in 'a'.code..'z'.code || + character in '0'.code..'9'.code || + character == '-'.code || character == '.'.code || character == '_'.code || + character == '~'.code || character == '+'.code || character == '/'.code || character == '='.code +} + +private const val MAXIMUM_BEARER_TOKEN_BYTES: Int = 8 * 1_024 diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/SHA256SUMS b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/SHA256SUMS new file mode 100644 index 0000000..9a4e1da --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/SHA256SUMS @@ -0,0 +1,11 @@ +1494c67baeded371a523928aff8195ad99c1065bb9bb804c103316dd8c6b5039 V001__identity_foundation.sql +35d32183391861dca8374f40394e6433395ebf2646a5fad5b977368a351d0b25 V002__federated_session_provenance.sql +96325b7e8273f8de14bededac4c849cf9c3fa5d40b6083b5fa6c1fc63702f882 V003__organization_audit_reads.sql +d55691f80f81348bd6633ceaeb09d83123dcdc9ad054b593ef9740c53e27de73 V004__audit_retention.sql +ad4af1ba1e9de44c15eced7609aa1f67bb248d3f812acb7b76e182252d3417bd V005__device_grant_cas_serialization.sql +c1e54e438d0fa10626aa55912891be0ccd269993e6d1ea07bbcd44c788e8a3ea V006__identity_session_touch.sql +1b3a48a4d253aff460b322201016280b1b9f5f71c6321b723ae657d0024bf6eb V007__administrative_recovery_activation.sql +1258206ab0171f7c55544625be90c0b879a4c8ee2658853e92510453b9be6e6b V008__device_membership_binding.sql +5300ed2b69edc7b40de7b5c669261e67f3d8b7db7a2c60663b2b2325a8b1b210 V009__fail_closed_environment_marker.sql +36c6ea68750b8177330775ba298ea92508df8aa03b06226011398bc4807e1991 V010__terminal_webauthn_attempts.sql +993781326772192f180755ff26b6b640cc3e945601d44edcd446528486bf2abd V011__federation_provider_lifecycle.sql diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V001__identity_foundation.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V001__identity_foundation.sql new file mode 100644 index 0000000..3e668ef --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V001__identity_foundation.sql @@ -0,0 +1,2932 @@ +CREATE SCHEMA IF NOT EXISTS aether_identity; + +CREATE TABLE IF NOT EXISTS aether_identity.schema_migrations ( + module TEXT NOT NULL, + version INTEGER NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (module, version) +); + +CREATE TABLE IF NOT EXISTS aether_identity.environment ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + environment TEXT NOT NULL CHECK (environment IN ('development', 'test', 'staging', 'production')), + namespace TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS aether_identity.bootstrap_receipts ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + digest_algorithm TEXT NOT NULL CHECK (digest_algorithm = 'sha256'), + digest_encoded TEXT NOT NULL UNIQUE, + consumed_at TIMESTAMPTZ NOT NULL +); + +-- Canonical model JSON is retained alongside relational lookup/CAS columns. This keeps the wire +-- representation lossless while the columns below enforce tenant, uniqueness, and state rules. +CREATE TABLE IF NOT EXISTS aether_identity.users ( + id TEXT PRIMARY KEY, + primary_email TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'active', 'suspended', 'deactivated')), + session_epoch BIGINT NOT NULL CHECK (session_epoch >= 0), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK ((document->>'version')::BIGINT = version), + CHECK ((document->>'sessionEpoch')::BIGINT = session_epoch), + CHECK (document->>'state' = state) +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_primary_email_normalized_idx + ON aether_identity.users (lower(primary_email)) + WHERE primary_email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS aether_identity.credentials ( + id TEXT PRIMARY KEY, + web_authn_id TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES aether_identity.users(id), + state TEXT NOT NULL CHECK (state IN ('active', 'suspended', 'suspected_clone', 'revoked')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'webAuthnId' = web_authn_id), + CHECK (document->>'userId' = user_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS credentials_user_idx + ON aether_identity.credentials (user_id, id); + +CREATE TABLE IF NOT EXISTS aether_identity.sessions ( + id TEXT PRIMARY KEY, + family_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES aether_identity.users(id), + token_digest_algorithm TEXT NOT NULL CHECK (token_digest_algorithm IN ('sha256', 'hmac_sha256')), + token_digest_encoded TEXT NOT NULL, + token_digest_key_version TEXT, + csrf_digest_algorithm TEXT NOT NULL CHECK (csrf_digest_algorithm IN ('sha256', 'hmac_sha256')), + csrf_digest_encoded TEXT NOT NULL, + csrf_digest_key_version TEXT, + state TEXT NOT NULL CHECK (state IN ('active', 'rotated', 'revoked', 'expired')), + user_session_epoch BIGINT NOT NULL CHECK (user_session_epoch >= 0), + version BIGINT NOT NULL CHECK (version >= 0), + idle_expires_at TIMESTAMPTZ NOT NULL, + absolute_expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'familyId' = family_id), + CHECK (document->>'userId' = user_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK ((document->>'userSessionEpoch')::BIGINT = user_session_epoch), + CHECK (document->>'state' = state), + CHECK (idle_expires_at <= absolute_expires_at) +); + +CREATE INDEX IF NOT EXISTS sessions_user_idx + ON aether_identity.sessions (user_id, id); + +CREATE INDEX IF NOT EXISTS sessions_family_idx + ON aether_identity.sessions (family_id, id); + +CREATE UNIQUE INDEX IF NOT EXISTS sessions_token_digest_idx + ON aether_identity.sessions ( + token_digest_algorithm, token_digest_encoded, COALESCE(token_digest_key_version, '') + ); + +CREATE UNIQUE INDEX IF NOT EXISTS sessions_csrf_digest_idx + ON aether_identity.sessions ( + csrf_digest_algorithm, csrf_digest_encoded, COALESCE(csrf_digest_key_version, '') + ); + +CREATE TABLE IF NOT EXISTS aether_identity.organizations ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('active', 'suspended', 'deleted')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'slug' = slug), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE TABLE IF NOT EXISTS aether_identity.memberships ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + user_id TEXT NOT NULL REFERENCES aether_identity.users(id), + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'publisher', 'viewer')), + state TEXT NOT NULL CHECK (state IN ('active', 'suspended', 'removed')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + UNIQUE (organization_id, user_id), + CHECK (document->>'id' = id), + CHECK (document->>'organizationId' = organization_id), + CHECK (document->>'userId' = user_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'role' = role), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS memberships_user_idx + ON aether_identity.memberships (user_id, organization_id); + +CREATE TABLE IF NOT EXISTS aether_identity.invitations ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + email TEXT NOT NULL, + token_digest_algorithm TEXT NOT NULL CHECK (token_digest_algorithm IN ('sha256', 'hmac_sha256')), + token_digest_encoded TEXT NOT NULL, + token_digest_key_version TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'accepted', 'revoked', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'organizationId' = organization_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_digest_idx + ON aether_identity.invitations ( + token_digest_algorithm, token_digest_encoded, COALESCE(token_digest_key_version, '') + ); + +CREATE UNIQUE INDEX IF NOT EXISTS invitations_pending_organization_email_idx + ON aether_identity.invitations (organization_id, lower(email)) + WHERE state = 'pending'; + +CREATE INDEX IF NOT EXISTS invitations_organization_idx + ON aether_identity.invitations (organization_id, id); + +CREATE TABLE IF NOT EXISTS aether_identity.service_identities ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + name TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'suspended', 'revoked')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + UNIQUE (organization_id, name), + CHECK (document->>'id' = id), + CHECK (document->>'organizationId' = organization_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS service_identities_organization_idx + ON aether_identity.service_identities (organization_id, id); + +CREATE TABLE IF NOT EXISTS aether_identity.service_credentials ( + id TEXT PRIMARY KEY, + service_identity_id TEXT NOT NULL REFERENCES aether_identity.service_identities(id), + public_prefix TEXT NOT NULL UNIQUE, + secret_digest_algorithm TEXT NOT NULL CHECK (secret_digest_algorithm IN ('sha256', 'hmac_sha256')), + secret_digest_encoded TEXT NOT NULL, + secret_digest_key_version TEXT, + state TEXT NOT NULL CHECK (state IN ('active', 'rotated', 'revoked', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'serviceIdentityId' = service_identity_id), + CHECK (document->>'publicPrefix' = public_prefix), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE UNIQUE INDEX IF NOT EXISTS service_credentials_secret_digest_idx + ON aether_identity.service_credentials ( + secret_digest_algorithm, secret_digest_encoded, COALESCE(secret_digest_key_version, '') + ); + +CREATE INDEX IF NOT EXISTS service_credentials_identity_idx + ON aether_identity.service_credentials (service_identity_id, id); + +CREATE TABLE IF NOT EXISTS aether_identity.challenges ( + id TEXT PRIMARY KEY, + purpose TEXT NOT NULL CHECK (purpose IN ( + 'webauthn_registration', 'webauthn_authentication', 'step_up', 'account_recovery', + 'invitation_acceptance', 'device_authorization', 'external_identity_link', + 'service_credential_rotation' + )), + challenge_digest_algorithm TEXT NOT NULL CHECK (challenge_digest_algorithm IN ('sha256', 'hmac_sha256')), + challenge_digest_encoded TEXT NOT NULL, + challenge_digest_key_version TEXT, + binding_digest_algorithm TEXT NOT NULL CHECK (binding_digest_algorithm IN ('sha256', 'hmac_sha256')), + binding_digest_encoded TEXT NOT NULL, + binding_digest_key_version TEXT, + payload_digest_algorithm TEXT, + payload_digest_encoded TEXT, + payload_digest_key_version TEXT, + user_id TEXT REFERENCES aether_identity.users(id), + organization_id TEXT REFERENCES aether_identity.organizations(id), + state TEXT NOT NULL CHECK (state IN ('pending', 'consumed', 'failed', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'purpose' = purpose), + CHECK (document->>'state' = state), + CHECK ((payload_digest_algorithm IS NULL) = (payload_digest_encoded IS NULL)) +); + +CREATE INDEX IF NOT EXISTS challenges_expiry_idx + ON aether_identity.challenges (expires_at) + WHERE state = 'pending'; + +CREATE UNIQUE INDEX IF NOT EXISTS challenges_digest_idx + ON aether_identity.challenges ( + challenge_digest_algorithm, challenge_digest_encoded, COALESCE(challenge_digest_key_version, '') + ); + +CREATE TABLE IF NOT EXISTS aether_identity.recovery_codes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES aether_identity.users(id), + generation BIGINT NOT NULL CHECK (generation >= 0), + public_selector TEXT NOT NULL UNIQUE, + secret_digest_algorithm TEXT NOT NULL CHECK (secret_digest_algorithm IN ('sha256', 'hmac_sha256')), + secret_digest_encoded TEXT NOT NULL, + secret_digest_key_version TEXT, + state TEXT NOT NULL CHECK (state IN ('active', 'consumed', 'revoked', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'userId' = user_id), + CHECK ((document->>'generation')::BIGINT = generation), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'publicSelector' = public_selector), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS recovery_codes_user_generation_idx + ON aether_identity.recovery_codes (user_id, generation, id); + +CREATE UNIQUE INDEX IF NOT EXISTS recovery_codes_secret_digest_idx + ON aether_identity.recovery_codes ( + secret_digest_algorithm, secret_digest_encoded, COALESCE(secret_digest_key_version, '') + ); + +CREATE TABLE IF NOT EXISTS aether_identity.device_grants ( + id TEXT PRIMARY KEY, + device_digest_algorithm TEXT NOT NULL CHECK (device_digest_algorithm IN ('sha256', 'hmac_sha256')), + device_digest_encoded TEXT NOT NULL, + device_digest_key_version TEXT, + user_digest_algorithm TEXT NOT NULL CHECK (user_digest_algorithm IN ('sha256', 'hmac_sha256')), + user_digest_encoded TEXT NOT NULL, + user_digest_key_version TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'authorized', 'denied', 'consumed', 'expired', 'cancelled')), + version BIGINT NOT NULL CHECK (version >= 0), + expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE UNIQUE INDEX IF NOT EXISTS device_grants_device_digest_idx + ON aether_identity.device_grants ( + device_digest_algorithm, device_digest_encoded, COALESCE(device_digest_key_version, '') + ); + +CREATE UNIQUE INDEX IF NOT EXISTS device_grants_user_digest_idx + ON aether_identity.device_grants ( + user_digest_algorithm, user_digest_encoded, COALESCE(user_digest_key_version, '') + ); + +CREATE TABLE IF NOT EXISTS aether_identity.device_grant_digest_reservations ( + digest_algorithm TEXT NOT NULL CHECK (digest_algorithm IN ('sha256', 'hmac_sha256')), + digest_encoded TEXT NOT NULL, + digest_key_version TEXT NOT NULL, + grant_id TEXT NOT NULL REFERENCES aether_identity.device_grants(id) ON DELETE CASCADE, + digest_kind TEXT NOT NULL CHECK (digest_kind IN ('device', 'user')), + PRIMARY KEY (digest_algorithm, digest_encoded, digest_key_version), + UNIQUE (grant_id, digest_kind) +); + +CREATE TABLE IF NOT EXISTS aether_identity.device_token_families ( + id TEXT PRIMARY KEY, + device_grant_id TEXT NOT NULL REFERENCES aether_identity.device_grants(id), + user_id TEXT NOT NULL REFERENCES aether_identity.users(id), + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + state TEXT NOT NULL CHECK (state IN ('active', 'revoked', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'deviceGrantId' = device_grant_id), + CHECK (document->>'userId' = user_id), + CHECK (document->>'organizationId' = organization_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS device_token_families_user_organization_idx + ON aether_identity.device_token_families (user_id, organization_id, id); + +CREATE TABLE IF NOT EXISTS aether_identity.device_access_tokens ( + id TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES aether_identity.device_token_families(id), + public_selector TEXT NOT NULL, + secret_digest_algorithm TEXT NOT NULL CHECK (secret_digest_algorithm IN ('sha256', 'hmac_sha256')), + secret_digest_encoded TEXT NOT NULL, + secret_digest_key_version TEXT, + state TEXT NOT NULL CHECK (state IN ('active', 'revoked', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'familyId' = family_id), + CHECK (document->>'publicSelector' = public_selector), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS device_access_tokens_family_idx + ON aether_identity.device_access_tokens (family_id, id); + +CREATE TABLE IF NOT EXISTS aether_identity.device_refresh_tokens ( + id TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES aether_identity.device_token_families(id), + public_selector TEXT NOT NULL, + secret_digest_algorithm TEXT NOT NULL CHECK (secret_digest_algorithm IN ('sha256', 'hmac_sha256')), + secret_digest_encoded TEXT NOT NULL, + secret_digest_key_version TEXT, + rotation_counter BIGINT NOT NULL CHECK (rotation_counter >= 0), + state TEXT NOT NULL CHECK (state IN ('active', 'rotated', 'revoked', 'expired')), + version BIGINT NOT NULL CHECK (version >= 0), + expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'familyId' = family_id), + CHECK (document->>'publicSelector' = public_selector), + CHECK ((document->>'rotationCounter')::BIGINT = rotation_counter), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state) +); + +CREATE INDEX IF NOT EXISTS device_refresh_tokens_family_idx + ON aether_identity.device_refresh_tokens (family_id, id); + +-- A single reservation surface prevents selector or digest reuse across access and refresh tokens. +CREATE TABLE IF NOT EXISTS aether_identity.device_token_credential_reservations ( + token_kind TEXT NOT NULL CHECK (token_kind IN ('access', 'refresh')), + token_id TEXT NOT NULL, + public_selector TEXT NOT NULL UNIQUE, + digest_algorithm TEXT NOT NULL CHECK (digest_algorithm IN ('sha256', 'hmac_sha256')), + digest_encoded TEXT NOT NULL, + digest_key_version TEXT NOT NULL, + PRIMARY KEY (token_kind, token_id), + UNIQUE (digest_algorithm, digest_encoded, digest_key_version) +); + +CREATE TABLE IF NOT EXISTS aether_identity.external_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES aether_identity.users(id), + provider TEXT NOT NULL, + subject TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'suspended', 'unlinked')), + version BIGINT NOT NULL CHECK (version >= 0), + document JSONB NOT NULL, + UNIQUE (provider, subject), + CHECK (document->>'id' = id), + CHECK (document->>'userId' = user_id), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'provider' = provider), + CHECK (document->>'subject' = subject), + CHECK (document->>'state' = state) +); + +CREATE TABLE IF NOT EXISTS aether_identity.external_replay_receipts ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + assertion_digest_algorithm TEXT NOT NULL CHECK (assertion_digest_algorithm IN ('sha256', 'hmac_sha256')), + assertion_digest_encoded TEXT NOT NULL, + assertion_digest_key_version TEXT, + expires_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'provider' = provider) +); + +CREATE UNIQUE INDEX IF NOT EXISTS external_replay_receipts_digest_idx + ON aether_identity.external_replay_receipts ( + provider, assertion_digest_algorithm, assertion_digest_encoded, COALESCE(assertion_digest_key_version, '') + ); + +CREATE TABLE IF NOT EXISTS aether_identity.audit_events ( + id TEXT PRIMARY KEY, + organization_id TEXT REFERENCES aether_identity.organizations(id), + action TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('succeeded', 'denied', 'failed')), + occurred_at TIMESTAMPTZ NOT NULL, + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'action' = action), + CHECK (document->>'outcome' = outcome) +); + +CREATE INDEX IF NOT EXISTS audit_events_organization_time_idx + ON aether_identity.audit_events (organization_id, occurred_at DESC); + +CREATE TABLE IF NOT EXISTS aether_identity.scim_operations ( + operation_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + mutation JSONB NOT NULL, + commit_result JSONB NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL +); + +CREATE OR REPLACE FUNCTION aether_identity.rpc_success(p_operation TEXT, p_result JSONB) +RETURNS JSONB +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT jsonb_build_object( + 'protocolVersion', 1, + 'operation', p_operation, + 'outcome', 'success', + 'result', COALESCE(p_result, 'null'::JSONB) + ); +$$; + +CREATE OR REPLACE FUNCTION aether_identity.assert_environment( + p_environment TEXT, + p_namespace TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + stored aether_identity.environment%ROWTYPE; +BEGIN + INSERT INTO aether_identity.environment(singleton, environment, namespace) + VALUES (TRUE, p_environment, p_namespace) + ON CONFLICT (singleton) DO NOTHING; + + SELECT * INTO stored FROM aether_identity.environment WHERE singleton = TRUE; + IF stored.environment <> p_environment OR stored.namespace <> p_namespace THEN + RAISE EXCEPTION 'identity environment mismatch' USING ERRCODE = 'A0001'; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.rpc_payload( + p_request JSONB, + p_expected_operation TEXT +) RETURNS JSONB +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_request IS NULL OR + jsonb_typeof(p_request) IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'invalid identity rpc envelope root' USING ERRCODE = 'A0014'; + ELSIF jsonb_typeof(p_request->'protocolVersion') IS DISTINCT FROM 'number' OR + p_request->>'protocolVersion' <> '1' THEN + RAISE EXCEPTION 'invalid identity rpc protocol version' USING ERRCODE = 'A0014'; + ELSIF p_request->>'operation' IS DISTINCT FROM p_expected_operation THEN + RAISE EXCEPTION 'invalid identity rpc operation' USING ERRCODE = 'A0014'; + ELSIF p_request->>'namespace' IS NULL OR p_request->>'environment' IS NULL THEN + RAISE EXCEPTION 'invalid identity rpc environment marker' USING ERRCODE = 'A0014'; + ELSIF jsonb_typeof(p_request->'payload') IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'invalid identity rpc payload' USING ERRCODE = 'A0014'; + END IF; + PERFORM aether_identity.assert_environment( + p_request->>'environment', + p_request->>'namespace' + ); + RETURN p_request->'payload'; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.record_audit(p_event JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO aether_identity.audit_events(id, organization_id, action, outcome, occurred_at, document) + VALUES ( + p_event->>'id', + NULLIF(p_event->>'organizationId', ''), + p_event->>'action', + p_event->>'outcome', + (p_event->>'occurredAt')::TIMESTAMPTZ, + p_event + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_user(p_user JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF (p_user->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new user version is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.users(id, primary_email, state, session_epoch, version, document) + VALUES ( + p_user->>'id', NULLIF(p_user->>'primaryEmail', ''), p_user->>'state', + (p_user->>'sessionEpoch')::BIGINT, (p_user->>'version')::BIGINT, p_user + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.replace_user(p_user JSONB, p_expected_version BIGINT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF (p_user->>'version')::BIGINT <> p_expected_version + 1 THEN + RAISE EXCEPTION 'replacement user version is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.users + SET primary_email = NULLIF(p_user->>'primaryEmail', ''), + state = p_user->>'state', + session_epoch = (p_user->>'sessionEpoch')::BIGINT, + version = (p_user->>'version')::BIGINT, + document = p_user + WHERE id = p_user->>'id' AND version = p_expected_version; + IF NOT FOUND THEN + IF EXISTS (SELECT 1 FROM aether_identity.users WHERE id = p_user->>'id') THEN + RAISE EXCEPTION 'user version conflict' USING ERRCODE = 'A0002'; + END IF; + RAISE EXCEPTION 'user not found' USING ERRCODE = 'A0012'; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_credential(p_credential JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_credential->>'state' <> 'active' OR (p_credential->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new credential is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.credentials(id, web_authn_id, user_id, state, version, document) + VALUES ( + p_credential->>'id', p_credential->>'webAuthnId', p_credential->>'userId', p_credential->>'state', + (p_credential->>'version')::BIGINT, p_credential + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_session(p_session JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + stored_user aether_identity.users%ROWTYPE; +BEGIN + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = p_session->>'userId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'session user not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_user.state <> 'active' OR + stored_user.session_epoch <> (p_session->>'userSessionEpoch')::BIGINT OR + p_session->>'state' <> 'active' OR + (p_session->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new session is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.sessions( + id, family_id, user_id, + token_digest_algorithm, token_digest_encoded, token_digest_key_version, + csrf_digest_algorithm, csrf_digest_encoded, csrf_digest_key_version, + state, user_session_epoch, version, idle_expires_at, absolute_expires_at, document + ) VALUES ( + p_session->>'id', p_session->>'familyId', p_session->>'userId', + p_session#>>'{tokenDigest,algorithm}', p_session#>>'{tokenDigest,encoded}', p_session#>>'{tokenDigest,keyVersion}', + p_session#>>'{csrfDigest,algorithm}', p_session#>>'{csrfDigest,encoded}', p_session#>>'{csrfDigest,keyVersion}', + p_session->>'state', (p_session->>'userSessionEpoch')::BIGINT, (p_session->>'version')::BIGINT, + (p_session->>'idleExpiresAt')::TIMESTAMPTZ, (p_session->>'absoluteExpiresAt')::TIMESTAMPTZ, p_session + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_membership(p_membership JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_membership->>'state' <> 'active' OR (p_membership->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new membership is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.memberships( + id, organization_id, user_id, role, state, version, document + ) VALUES ( + p_membership->>'id', p_membership->>'organizationId', p_membership->>'userId', + p_membership->>'role', p_membership->>'state', (p_membership->>'version')::BIGINT, p_membership + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_recovery_code(p_code JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_code->>'state' <> 'active' OR (p_code->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new recovery code is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.recovery_codes( + id, user_id, generation, public_selector, + secret_digest_algorithm, secret_digest_encoded, secret_digest_key_version, + state, version, document + ) VALUES ( + p_code->>'id', p_code->>'userId', (p_code->>'generation')::BIGINT, p_code->>'publicSelector', + p_code#>>'{secretDigest,algorithm}', p_code#>>'{secretDigest,encoded}', p_code#>>'{secretDigest,keyVersion}', + p_code->>'state', (p_code->>'version')::BIGINT, p_code + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_service_credential(p_credential JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_credential->>'state' <> 'active' OR (p_credential->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new service credential is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.service_credentials( + id, service_identity_id, public_prefix, + secret_digest_algorithm, secret_digest_encoded, secret_digest_key_version, + state, version, document + ) VALUES ( + p_credential->>'id', p_credential->>'serviceIdentityId', p_credential->>'publicPrefix', + p_credential#>>'{secretDigest,algorithm}', p_credential#>>'{secretDigest,encoded}', + p_credential#>>'{secretDigest,keyVersion}', p_credential->>'state', + (p_credential->>'version')::BIGINT, p_credential + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_external_replay(p_receipt JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO aether_identity.external_replay_receipts( + id, provider, assertion_digest_algorithm, assertion_digest_encoded, + assertion_digest_key_version, expires_at, document + ) VALUES ( + p_receipt->>'id', p_receipt->>'provider', p_receipt#>>'{assertionDigest,algorithm}', + p_receipt#>>'{assertionDigest,encoded}', p_receipt#>>'{assertionDigest,keyVersion}', + (p_receipt->>'expiresAt')::TIMESTAMPTZ, p_receipt + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.consume_challenge_model( + p_id TEXT, + p_expected_version BIGINT, + p_terminal_state TEXT, + p_consumed_at TIMESTAMPTZ +) RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + stored aether_identity.challenges%ROWTYPE; + replacement JSONB; +BEGIN + SELECT * INTO stored FROM aether_identity.challenges WHERE id = p_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'challenge not found' USING ERRCODE = 'A0012'; + END IF; + IF stored.version <> p_expected_version THEN + RAISE EXCEPTION 'challenge version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'pending' THEN + RAISE EXCEPTION 'challenge is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored.expires_at <= p_consumed_at THEN + RAISE EXCEPTION 'challenge expired' USING ERRCODE = 'A0008'; + END IF; + + replacement := stored.document || jsonb_build_object( + 'state', p_terminal_state, + 'consumedAt', to_jsonb(p_consumed_at), + 'attemptCount', (stored.document->>'attemptCount')::INTEGER + + CASE WHEN p_terminal_state = 'failed' THEN 1 ELSE 0 END, + 'version', stored.version + 1 + ); + UPDATE aether_identity.challenges + SET state = p_terminal_state, version = stored.version + 1, document = replacement + WHERE id = p_id; + RETURN replacement; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_assert_environment(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM aether_identity.rpc_payload(p_request, 'assert_environment'); + RETURN aether_identity.rpc_success( + 'assert_environment', + jsonb_build_object( + 'verified', TRUE, + 'environment', p_request->>'environment', + 'namespace', p_request->>'namespace' + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_user(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_user'); + SELECT document INTO result FROM aether_identity.users WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_user', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_user_by_email(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_user_by_email'); + SELECT document INTO result FROM aether_identity.users WHERE primary_email = payload->>'email'; + RETURN aether_identity.rpc_success('find_user_by_email', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_credential(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_credential'); + SELECT document INTO result FROM aether_identity.credentials WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_credential', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_credential_by_web_authn_id(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_credential_by_web_authn_id'); + SELECT document INTO result FROM aether_identity.credentials + WHERE web_authn_id = payload->>'webAuthnId'; + RETURN aether_identity.rpc_success('find_credential_by_web_authn_id', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_credentials_for_user(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_credentials_for_user'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.credentials WHERE user_id = payload->>'userId'; + RETURN aether_identity.rpc_success('list_credentials_for_user', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_session(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_session'); + SELECT document INTO result FROM aether_identity.sessions WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_session', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_sessions_for_user(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_sessions_for_user'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.sessions WHERE user_id = payload->>'userId'; + RETURN aether_identity.rpc_success('list_sessions_for_user', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_organization(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_organization'); + SELECT document INTO result FROM aether_identity.organizations WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_organization', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_membership(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_membership'); + SELECT document INTO result FROM aether_identity.memberships WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_membership', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_membership_for_user(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_membership_for_user'); + SELECT document INTO result FROM aether_identity.memberships + WHERE user_id = payload->>'userId' AND organization_id = payload->>'organizationId'; + RETURN aether_identity.rpc_success('find_membership_for_user', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_invitation(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_invitation'); + SELECT document INTO result FROM aether_identity.invitations WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_invitation', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_service_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_service_identity'); + SELECT document INTO result FROM aether_identity.service_identities WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_service_identity', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_service_credential_by_prefix(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_service_credential_by_prefix'); + SELECT document INTO result FROM aether_identity.service_credentials + WHERE public_prefix = payload->>'publicPrefix'; + RETURN aether_identity.rpc_success('find_service_credential_by_prefix', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_external_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_external_identity'); + SELECT document INTO result FROM aether_identity.external_identities + WHERE provider = payload->>'provider' AND subject = payload->>'subject'; + RETURN aether_identity.rpc_success('find_external_identity', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_challenge(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_challenge'); + SELECT document INTO result FROM aether_identity.challenges WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_challenge', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_recovery_code_by_selector(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_recovery_code_by_selector'); + SELECT document INTO result FROM aether_identity.recovery_codes + WHERE public_selector = payload->>'publicSelector'; + RETURN aether_identity.rpc_success('find_recovery_code_by_selector', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_device_grant(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_device_grant'); + SELECT document INTO result FROM aether_identity.device_grants WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_device_grant', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_challenge(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; challenge JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_challenge'); + challenge := payload->'challenge'; + IF challenge->>'state' <> 'pending' OR + (challenge->>'version')::BIGINT <> 0 OR + (challenge->>'attemptCount')::INTEGER <> 0 THEN + RAISE EXCEPTION 'new challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.challenges( + id, purpose, + challenge_digest_algorithm, challenge_digest_encoded, challenge_digest_key_version, + binding_digest_algorithm, binding_digest_encoded, binding_digest_key_version, + payload_digest_algorithm, payload_digest_encoded, payload_digest_key_version, + user_id, organization_id, state, version, expires_at, document + ) VALUES ( + challenge->>'id', challenge->>'purpose', + challenge#>>'{challengeDigest,algorithm}', challenge#>>'{challengeDigest,encoded}', + challenge#>>'{challengeDigest,keyVersion}', challenge#>>'{bindingDigest,algorithm}', + challenge#>>'{bindingDigest,encoded}', challenge#>>'{bindingDigest,keyVersion}', + challenge#>>'{payloadDigest,algorithm}', challenge#>>'{payloadDigest,encoded}', + challenge#>>'{payloadDigest,keyVersion}', NULLIF(challenge->>'userId', ''), + NULLIF(challenge->>'organizationId', ''), challenge->>'state', + (challenge->>'version')::BIGINT, (challenge->>'expiresAt')::TIMESTAMPTZ, challenge + ); + RETURN aether_identity.rpc_success('create_challenge', challenge); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_consume_challenge(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; challenge JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'consume_challenge'); + challenge := aether_identity.consume_challenge_model( + payload->>'challengeId', + (payload->>'expectedVersion')::BIGINT, + payload->>'terminalState', + (payload->>'consumedAt')::TIMESTAMPTZ + ); + IF payload->'auditEvent' IS NOT NULL AND payload->'auditEvent' <> 'null'::JSONB THEN + PERFORM aether_identity.record_audit(payload->'auditEvent'); + END IF; + RETURN aether_identity.rpc_success('consume_challenge', challenge); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_complete_credential_registration(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; challenge JSONB; credential JSONB; user_document JSONB; audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'complete_credential_registration'); + credential := payload->'credential'; + user_document := payload->'user'; + audit_event := payload->'auditEvent'; + + challenge := aether_identity.consume_challenge_model( + payload->>'challengeId', + (payload->>'expectedChallengeVersion')::BIGINT, + 'consumed', + (audit_event->>'occurredAt')::TIMESTAMPTZ + ); + IF challenge->>'purpose' <> 'webauthn_registration' OR + (challenge->'userId' <> 'null'::JSONB AND challenge->>'userId' <> credential->>'userId') THEN + RAISE EXCEPTION 'registration challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + + IF user_document IS NOT NULL AND user_document <> 'null'::JSONB THEN + IF (payload->>'expectedUserVersion')::BIGINT = -1 THEN + PERFORM aether_identity.insert_user(user_document); + ELSE + PERFORM aether_identity.replace_user(user_document, (payload->>'expectedUserVersion')::BIGINT); + END IF; + ELSIF NOT EXISTS ( + SELECT 1 FROM aether_identity.users WHERE id = credential->>'userId' + ) THEN + RAISE EXCEPTION 'credential user not found' USING ERRCODE = 'A0012'; + END IF; + PERFORM aether_identity.insert_credential(credential); + PERFORM aether_identity.record_audit(audit_event); + + RETURN aether_identity.rpc_success( + 'complete_credential_registration', + jsonb_build_object( + 'challenge', challenge, + 'credential', credential, + 'user', user_document, + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_complete_credential_authentication(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + challenge JSONB; + stored_user aether_identity.users%ROWTYPE; + stored_credential aether_identity.credentials%ROWTYPE; + credential JSONB; + session_document JSONB; + replaced_session aether_identity.sessions%ROWTYPE; + replaced_document JSONB := NULL; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'complete_credential_authentication'); + session_document := payload->'session'; + audit_event := payload->'auditEvent'; + + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = session_document->>'userId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'authentication user not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_user.state <> 'active' OR + stored_user.session_epoch <> (session_document->>'userSessionEpoch')::BIGINT OR + (session_document->>'createdAt')::TIMESTAMPTZ <> + (payload->>'authenticatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'authentication session epoch is invalid' USING ERRCODE = 'A0003'; + END IF; + + SELECT * INTO stored_credential FROM aether_identity.credentials + WHERE id = payload->>'credentialId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'credential not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_credential.version <> (payload->>'expectedCredentialVersion')::BIGINT THEN + RAISE EXCEPTION 'credential version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_credential.state <> 'active' THEN + RAISE EXCEPTION 'credential transition invalid' USING ERRCODE = 'A0003'; + END IF; + IF stored_credential.user_id <> stored_user.id OR + ((stored_credential.document->>'signCount')::BIGINT <> 0 AND + (payload->>'newSignCount')::BIGINT <> 0 AND + (payload->>'newSignCount')::BIGINT <= (stored_credential.document->>'signCount')::BIGINT) OR + (stored_credential.document->>'backupEligible')::BOOLEAN IS DISTINCT FROM + (payload->>'backupEligible')::BOOLEAN THEN + RAISE EXCEPTION 'credential authentication transition invalid' USING ERRCODE = 'A0003'; + END IF; + + credential := stored_credential.document || jsonb_build_object( + 'signCount', (payload->>'newSignCount')::BIGINT, + 'backupEligible', (payload->>'backupEligible')::BOOLEAN, + 'backedUp', (payload->>'backedUp')::BOOLEAN, + 'lastUsedAt', payload->'authenticatedAt', + 'updatedAt', payload->'authenticatedAt', + 'version', stored_credential.version + 1 + ); + UPDATE aether_identity.credentials + SET version = stored_credential.version + 1, document = credential + WHERE id = stored_credential.id; + + IF payload->'replacedSessionId' IS NOT NULL AND payload->'replacedSessionId' <> 'null'::JSONB THEN + SELECT * INTO replaced_session FROM aether_identity.sessions + WHERE id = payload->>'replacedSessionId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'session not found' USING ERRCODE = 'A0012'; + END IF; + IF replaced_session.version <> (payload->>'expectedReplacedSessionVersion')::BIGINT THEN + RAISE EXCEPTION 'session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF replaced_session.state <> 'active' THEN + RAISE EXCEPTION 'session not active' USING ERRCODE = 'A0009'; + END IF; + IF replaced_session.absolute_expires_at <= (payload->>'authenticatedAt')::TIMESTAMPTZ OR + replaced_session.idle_expires_at <= (payload->>'authenticatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'session expired' USING ERRCODE = 'A0010'; + END IF; + IF replaced_session.user_id <> stored_credential.user_id OR + session_document->>'rotatedFromId' <> replaced_session.id OR + session_document->>'familyId' <> replaced_session.family_id OR + (session_document->>'rotationCounter')::BIGINT <> + (replaced_session.document->>'rotationCounter')::BIGINT + 1 THEN + RAISE EXCEPTION 'authentication session rotation is invalid' USING ERRCODE = 'A0003'; + END IF; + replaced_document := replaced_session.document || jsonb_build_object( + 'state', 'rotated', + 'rotatedToId', session_document->'id', + 'version', replaced_session.version + 1 + ); + UPDATE aether_identity.sessions + SET state = 'rotated', version = replaced_session.version + 1, document = replaced_document + WHERE id = replaced_session.id; + ELSIF session_document->>'familyId' <> session_document->>'id' OR + session_document->'rotatedFromId' <> 'null'::JSONB OR + (session_document->>'rotationCounter')::BIGINT <> 0 THEN + RAISE EXCEPTION 'standalone authentication session is invalid' USING ERRCODE = 'A0003'; + END IF; + + challenge := aether_identity.consume_challenge_model( + payload->>'challengeId', + (payload->>'expectedChallengeVersion')::BIGINT, + 'consumed', + (payload->>'authenticatedAt')::TIMESTAMPTZ + ); + IF challenge->>'purpose' NOT IN ('webauthn_authentication', 'step_up') OR + (challenge->'userId' <> 'null'::JSONB AND challenge->>'userId' <> stored_credential.user_id) THEN + RAISE EXCEPTION 'authentication challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_session(session_document); + PERFORM aether_identity.record_audit(audit_event); + + RETURN aether_identity.rpc_success( + 'complete_credential_authentication', + jsonb_build_object( + 'challenge', challenge, + 'credential', credential, + 'session', session_document, + 'replacedSession', replaced_document, + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_quarantine_credential_authentication(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + challenge JSONB; + stored_credential aether_identity.credentials%ROWTYPE; + credential JSONB; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'quarantine_credential_authentication'); + audit_event := payload->'auditEvent'; + + SELECT * INTO stored_credential FROM aether_identity.credentials + WHERE id = payload->>'credentialId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'credential not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_credential.version <> (payload->>'expectedCredentialVersion')::BIGINT THEN + RAISE EXCEPTION 'credential version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_credential.state <> 'active' OR + (stored_credential.document->>'signCount')::BIGINT = 0 OR + (payload->>'observedSignCount')::BIGINT = 0 OR + (payload->>'observedSignCount')::BIGINT > + (stored_credential.document->>'signCount')::BIGINT OR + (stored_credential.document->>'backupEligible')::BOOLEAN IS DISTINCT FROM + (payload->>'backupEligible')::BOOLEAN THEN + RAISE EXCEPTION 'credential counter anomaly is invalid' USING ERRCODE = 'A0003'; + END IF; + + challenge := aether_identity.consume_challenge_model( + payload->>'challengeId', + (payload->>'expectedChallengeVersion')::BIGINT, + 'consumed', + (payload->>'detectedAt')::TIMESTAMPTZ + ); + IF challenge->>'purpose' NOT IN ('webauthn_authentication', 'step_up') OR + (challenge->'userId' <> 'null'::JSONB AND + challenge->>'userId' <> stored_credential.user_id) THEN + RAISE EXCEPTION 'quarantine challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + + credential := stored_credential.document || jsonb_build_object( + 'signCount', (payload->>'observedSignCount')::BIGINT, + 'backupEligible', (payload->>'backupEligible')::BOOLEAN, + 'backedUp', (payload->>'backedUp')::BOOLEAN, + 'state', 'suspected_clone', + 'version', stored_credential.version + 1, + 'updatedAt', payload->'detectedAt', + 'lastUsedAt', payload->'detectedAt', + 'revocationReasonCode', 'signature_counter_anomaly' + ); + UPDATE aether_identity.credentials + SET state = 'suspected_clone', version = stored_credential.version + 1, document = credential + WHERE id = stored_credential.id; + PERFORM aether_identity.record_audit(audit_event); + + RETURN aether_identity.rpc_success( + 'quarantine_credential_authentication', + jsonb_build_object('challenge', challenge, 'credential', credential, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_session(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; session_document JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_session'); + session_document := payload->'session'; + IF session_document->>'familyId' <> session_document->>'id' OR + session_document->'rotatedFromId' <> 'null'::JSONB OR + (session_document->>'rotationCounter')::BIGINT <> 0 THEN + RAISE EXCEPTION 'standalone session is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_session(session_document); + PERFORM aether_identity.record_audit(payload->'auditEvent'); + RETURN aether_identity.rpc_success('create_session', session_document); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_rotate_session(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; stored aether_identity.sessions%ROWTYPE; previous JSONB; replacement JSONB; audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'rotate_session'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + PERFORM 1 FROM aether_identity.users + WHERE id = replacement->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'session user not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO stored FROM aether_identity.sessions WHERE id = payload->>'sessionId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'session not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' THEN RAISE EXCEPTION 'session not active' USING ERRCODE = 'A0009'; END IF; + IF stored.absolute_expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ OR + stored.idle_expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'session expired' USING ERRCODE = 'A0010'; + END IF; + IF replacement->>'userId' <> stored.user_id OR + replacement->>'familyId' <> stored.family_id OR + replacement->>'rotatedFromId' <> stored.id OR + (replacement->>'rotationCounter')::BIGINT <> + (stored.document->>'rotationCounter')::BIGINT + 1 OR + (replacement->>'createdAt')::TIMESTAMPTZ <> + (payload->>'rotatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'session rotation is invalid' USING ERRCODE = 'A0003'; + END IF; + previous := stored.document || jsonb_build_object( + 'state', 'rotated', 'rotatedToId', replacement->'id', + 'rotatedAt', to_jsonb((payload->>'rotatedAt')::TIMESTAMPTZ), + 'version', stored.version + 1 + ); + UPDATE aether_identity.sessions + SET state = 'rotated', version = stored.version + 1, document = previous + WHERE id = stored.id; + PERFORM aether_identity.insert_session(replacement); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'rotate_session', + jsonb_build_object('previous', previous, 'replacement', replacement, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_revoke_session(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; stored aether_identity.sessions%ROWTYPE; replacement JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'revoke_session'); + SELECT * INTO stored FROM aether_identity.sessions WHERE id = payload->>'sessionId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'session not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' THEN RAISE EXCEPTION 'session not active' USING ERRCODE = 'A0009'; END IF; + replacement := stored.document || jsonb_build_object( + 'state', 'revoked', + 'revokedAt', payload->'revokedAt', + 'revocationReasonCode', payload->'reasonCode', + 'version', stored.version + 1 + ); + UPDATE aether_identity.sessions + SET state = 'revoked', version = stored.version + 1, document = replacement + WHERE id = stored.id; + PERFORM aether_identity.record_audit(payload->'auditEvent'); + RETURN aether_identity.rpc_success('revoke_session', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_revoke_user_sessions(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + stored_user aether_identity.users%ROWTYPE; + except_session aether_identity.sessions%ROWTYPE; + user_document JSONB; + revoked_ids JSONB; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'revoke_user_sessions'); + audit_event := payload->'auditEvent'; + SELECT * INTO stored_user FROM aether_identity.users WHERE id = payload->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'user not found' USING ERRCODE = 'A0012'; END IF; + IF stored_user.version <> (payload->>'expectedUserVersion')::BIGINT OR + stored_user.session_epoch <> (payload->>'expectedSessionEpoch')::BIGINT THEN + RAISE EXCEPTION 'user version conflict' USING ERRCODE = 'A0002'; + END IF; + IF payload->>'exceptSessionId' IS NOT NULL THEN + SELECT * INTO except_session FROM aether_identity.sessions + WHERE id = payload->>'exceptSessionId' FOR UPDATE; + IF NOT FOUND OR except_session.user_id <> stored_user.id OR except_session.state <> 'active' THEN + RAISE EXCEPTION 'excepted session is not active' USING ERRCODE = 'A0009'; + END IF; + END IF; + user_document := stored_user.document || jsonb_build_object( + 'sessionEpoch', (payload->>'newSessionEpoch')::BIGINT, + 'version', stored_user.version + 1, + 'updatedAt', payload->'revokedAt' + ); + UPDATE aether_identity.users + SET session_epoch = (payload->>'newSessionEpoch')::BIGINT, + version = stored_user.version + 1, + document = user_document + WHERE id = stored_user.id; + + WITH revoked AS ( + UPDATE aether_identity.sessions + SET state = 'revoked', + version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', 'revokedAt', payload->'revokedAt', + 'revocationReasonCode', payload->'reasonCode', 'version', version + 1 + ) + WHERE user_id = stored_user.id + AND state = 'active' + AND (payload->>'exceptSessionId' IS NULL OR id <> payload->>'exceptSessionId') + RETURNING id + ) SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::JSONB) INTO revoked_ids FROM revoked; + IF payload->>'exceptSessionId' IS NOT NULL THEN + UPDATE aether_identity.sessions + SET user_session_epoch = (payload->>'newSessionEpoch')::BIGINT, + version = version + 1, + document = document || jsonb_build_object( + 'userSessionEpoch', (payload->>'newSessionEpoch')::BIGINT, + 'version', version + 1 + ) + WHERE id = except_session.id; + END IF; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'revoke_user_sessions', + jsonb_build_object('user', user_document, 'revokedSessionIds', revoked_ids, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_replace_recovery_codes(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; current_generation BIGINT; code JSONB; audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'replace_recovery_codes'); + audit_event := payload->'auditEvent'; + PERFORM 1 FROM aether_identity.users WHERE id = payload->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery user not found' USING ERRCODE = 'A0012'; END IF; + PERFORM 1 FROM aether_identity.recovery_codes + WHERE user_id = payload->>'userId' FOR UPDATE; + SELECT MAX(generation) INTO current_generation FROM aether_identity.recovery_codes + WHERE user_id = payload->>'userId'; + IF current_generation IS DISTINCT FROM (payload->>'expectedGeneration')::BIGINT THEN + RAISE EXCEPTION 'recovery generation conflict' USING ERRCODE = 'A0002'; + END IF; + UPDATE aether_identity.recovery_codes + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object('state', 'revoked', 'version', version + 1) + WHERE user_id = payload->>'userId' AND state = 'active'; + FOR code IN SELECT value FROM jsonb_array_elements(payload->'codes') LOOP + IF code->>'userId' <> payload->>'userId' OR + (code->>'generation')::BIGINT <> (payload->>'newGeneration')::BIGINT THEN + RAISE EXCEPTION 'replacement recovery code is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_recovery_code(code); + END LOOP; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'replace_recovery_codes', + jsonb_build_object( + 'generation', (payload->>'newGeneration')::BIGINT, + 'codes', payload->'codes', + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_consume_recovery_code(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; stored aether_identity.recovery_codes%ROWTYPE; code JSONB; session_document JSONB; audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'consume_recovery_code'); + session_document := payload->'recoverySession'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.recovery_codes + WHERE id = payload->>'recoveryCodeId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery code not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'recovery code version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' OR + (stored.document->>'expiresAt' IS NOT NULL AND + (stored.document->>'expiresAt')::TIMESTAMPTZ <= (payload->>'consumedAt')::TIMESTAMPTZ) THEN + RAISE EXCEPTION 'recovery code is not active' USING ERRCODE = 'A0011'; + END IF; + code := stored.document || jsonb_build_object( + 'state', 'consumed', 'consumedAt', payload->'consumedAt', 'version', stored.version + 1 + ); + IF session_document->>'userId' <> stored.user_id OR + session_document->>'familyId' <> session_document->>'id' OR + session_document->'rotatedFromId' <> 'null'::JSONB OR + (session_document->>'rotationCounter')::BIGINT <> 0 OR + (session_document->>'createdAt')::TIMESTAMPTZ <> + (payload->>'consumedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'recovery session is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.recovery_codes + SET state = 'consumed', version = stored.version + 1, document = code WHERE id = stored.id; + PERFORM aether_identity.insert_session(session_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'consume_recovery_code', + jsonb_build_object('recoveryCode', code, 'recoverySession', session_document, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_membership(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + membership JSONB; + invitation aether_identity.invitations%ROWTYPE; + invitation_document JSONB; + membership_user aether_identity.users%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_membership'); + membership := payload->'membership'; + IF payload->'invitationId' IS NOT NULL AND payload->'invitationId' <> 'null'::JSONB THEN + SELECT * INTO invitation FROM aether_identity.invitations + WHERE id = payload->>'invitationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'invitation not found' USING ERRCODE = 'A0012'; END IF; + IF invitation.version <> (payload->>'expectedInvitationVersion')::BIGINT THEN + RAISE EXCEPTION 'invitation version conflict' USING ERRCODE = 'A0002'; + END IF; + IF invitation.state <> 'pending' OR + (invitation.document->>'expiresAt')::TIMESTAMPTZ <= (payload#>>'{auditEvent,occurredAt}')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'invitation transition invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO membership_user FROM aether_identity.users + WHERE id = membership->>'userId'; + IF NOT FOUND THEN RAISE EXCEPTION 'membership user not found' USING ERRCODE = 'A0012'; END IF; + IF invitation.organization_id <> membership->>'organizationId' OR + (membership_user.primary_email IS NOT NULL AND + lower(membership_user.primary_email) <> lower(invitation.email)) THEN + RAISE EXCEPTION 'invitation does not match membership' USING ERRCODE = 'A0003'; + END IF; + invitation_document := invitation.document || jsonb_build_object( + 'state', 'accepted', + 'acceptedAt', payload#>'{auditEvent,occurredAt}', + 'acceptedByUserId', membership->'userId', + 'version', invitation.version + 1 + ); + UPDATE aether_identity.invitations + SET state = 'accepted', version = invitation.version + 1, document = invitation_document + WHERE id = invitation.id; + END IF; + PERFORM aether_identity.insert_membership(membership); + PERFORM aether_identity.record_audit(payload->'auditEvent'); + RETURN aether_identity.rpc_success('create_membership', membership); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_mutate_membership(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + stored aether_identity.memberships%ROWTYPE; + replacement JSONB; + remaining_owners BIGINT; + locked_organization_id TEXT; + stored_user aether_identity.users%ROWTYPE; + replacement_user JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'mutate_membership'); + replacement := payload->'replacement'; + SELECT organization_id INTO locked_organization_id + FROM aether_identity.memberships WHERE id = payload->>'membershipId'; + IF NOT FOUND THEN RAISE EXCEPTION 'membership not found' USING ERRCODE = 'A0012'; END IF; + PERFORM 1 FROM aether_identity.organizations + WHERE id = locked_organization_id FOR UPDATE; + SELECT * INTO stored FROM aether_identity.memberships WHERE id = payload->>'membershipId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'membership not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'membership version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.organization_id <> replacement->>'organizationId' OR stored.user_id <> replacement->>'userId' THEN + RAISE EXCEPTION 'membership identity changed' USING ERRCODE = 'A0003'; + END IF; + IF (replacement->>'version')::BIGINT <> stored.version + 1 THEN + RAISE EXCEPTION 'membership replacement version is invalid' USING ERRCODE = 'A0003'; + END IF; + IF stored.role = 'owner' AND stored.state = 'active' AND + NOT (replacement->>'role' = 'owner' AND replacement->>'state' = 'active') THEN + SELECT COUNT(*) INTO remaining_owners FROM aether_identity.memberships + WHERE organization_id = stored.organization_id AND id <> stored.id + AND role = 'owner' AND state = 'active'; + IF remaining_owners = 0 THEN + RAISE EXCEPTION 'last owner cannot be removed' USING ERRCODE = 'A0004'; + END IF; + END IF; + IF payload->'expectedUserVersion' IS NOT NULL AND payload->'expectedUserVersion' <> 'null'::JSONB THEN + SELECT * INTO stored_user FROM aether_identity.users WHERE id = stored.user_id FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'membership user not found' USING ERRCODE = 'A0012'; END IF; + IF stored_user.version <> (payload->>'expectedUserVersion')::BIGINT OR + stored_user.session_epoch <> (payload->>'expectedSessionEpoch')::BIGINT THEN + RAISE EXCEPTION 'membership user version conflict' USING ERRCODE = 'A0002'; + END IF; + IF (payload->>'newSessionEpoch')::BIGINT <> stored_user.session_epoch + 1 OR + payload->>'sessionsRevokedAt' IS NULL OR + COALESCE(payload->>'sessionRevocationReasonCode', '') = '' OR + length(payload->>'sessionRevocationReasonCode') > 200 THEN + RAISE EXCEPTION 'membership session revocation is invalid' USING ERRCODE = 'A0003'; + END IF; + replacement_user := stored_user.document || jsonb_build_object( + 'sessionEpoch', stored_user.session_epoch + 1, + 'version', stored_user.version + 1, + 'updatedAt', payload->'sessionsRevokedAt' + ); + UPDATE aether_identity.users + SET session_epoch = stored_user.session_epoch + 1, + version = stored_user.version + 1, + document = replacement_user + WHERE id = stored_user.id; + UPDATE aether_identity.sessions + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', + 'version', version + 1, + 'revokedAt', payload->'sessionsRevokedAt', + 'revocationReasonCode', payload->'sessionRevocationReasonCode' + ) + WHERE user_id = stored_user.id AND state = 'active'; + ELSIF payload->'expectedSessionEpoch' <> 'null'::JSONB OR + payload->'newSessionEpoch' <> 'null'::JSONB OR + payload->'sessionsRevokedAt' <> 'null'::JSONB OR + payload->'sessionRevocationReasonCode' <> 'null'::JSONB THEN + RAISE EXCEPTION 'incomplete membership session mutation' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.memberships + SET role = replacement->>'role', state = replacement->>'state', + version = (replacement->>'version')::BIGINT, document = replacement + WHERE id = stored.id; + PERFORM aether_identity.record_audit(payload->'auditEvent'); + RETURN aether_identity.rpc_success('mutate_membership', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_compare_and_set_device_grant(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; replacement JSONB; stored aether_identity.device_grants%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'compare_and_set_device_grant'); + replacement := payload->'replacement'; + IF length(COALESCE(replacement->>'clientId', '')) NOT BETWEEN 1 AND 200 OR + replacement->>'clientId' ~ '[^!-~]' OR + length(COALESCE(replacement->>'clientName', '')) NOT BETWEEN 1 AND 200 OR + jsonb_typeof(replacement->'requestedCapabilities') IS DISTINCT FROM 'array' OR + jsonb_array_length(replacement->'requestedCapabilities') = 0 OR + jsonb_typeof(replacement->'approvedCapabilities') IS DISTINCT FROM 'array' OR + NOT ((replacement->'requestedCapabilities') @> (replacement->'approvedCapabilities')) OR + COALESCE((replacement->>'pollingIntervalSeconds')::INTEGER, 0) NOT BETWEEN 5 AND 300 OR + COALESCE((replacement->>'pollCount')::INTEGER, -1) < 0 OR + (replacement->>'expiresAt')::TIMESTAMPTZ <= (replacement->>'createdAt')::TIMESTAMPTZ OR + (replacement->>'state' = 'pending' AND jsonb_array_length(replacement->'approvedCapabilities') <> 0) OR + (replacement->>'state' IN ('authorized', 'consumed') AND ( + jsonb_array_length(replacement->'approvedCapabilities') = 0 OR + replacement->>'userId' IS NULL OR replacement->>'organizationId' IS NULL OR + replacement->>'authorizedByUserId' IS NULL OR replacement->>'authorizedAt' IS NULL + )) OR + (replacement->>'state' = 'denied' AND replacement->>'deniedAt' IS NULL) OR + (replacement->>'state' = 'consumed' AND replacement->>'consumedAt' IS NULL) OR + (replacement->>'state' = 'expired' AND replacement->>'expiredAt' IS NULL) OR + (replacement->>'state' = 'cancelled' AND replacement->>'cancelledAt' IS NULL) THEN + RAISE EXCEPTION 'device grant model is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored FROM aether_identity.device_grants WHERE id = replacement->>'id' FOR UPDATE; + IF payload->'expectedVersion' IS NULL OR payload->'expectedVersion' = 'null'::JSONB THEN + IF FOUND THEN RAISE EXCEPTION 'device grant already exists' USING ERRCODE = 'A0013'; END IF; + IF replacement->>'state' <> 'pending' OR + (replacement->>'version')::BIGINT <> 0 OR + replacement#>'{deviceCodeDigest}' = replacement#>'{userCodeDigest}' OR + EXISTS ( + SELECT 1 FROM aether_identity.device_grants + WHERE document#>'{deviceCodeDigest}' IN ( + replacement#>'{deviceCodeDigest}', replacement#>'{userCodeDigest}' + ) OR + document#>'{userCodeDigest}' IN ( + replacement#>'{deviceCodeDigest}', replacement#>'{userCodeDigest}' + ) + ) THEN + RAISE EXCEPTION 'new device grant is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.device_grants( + id, device_digest_algorithm, device_digest_encoded, device_digest_key_version, + user_digest_algorithm, user_digest_encoded, user_digest_key_version, + state, version, expires_at, document + ) VALUES ( + replacement->>'id', replacement#>>'{deviceCodeDigest,algorithm}', + replacement#>>'{deviceCodeDigest,encoded}', replacement#>>'{deviceCodeDigest,keyVersion}', + replacement#>>'{userCodeDigest,algorithm}', replacement#>>'{userCodeDigest,encoded}', + replacement#>>'{userCodeDigest,keyVersion}', replacement->>'state', + (replacement->>'version')::BIGINT, (replacement->>'expiresAt')::TIMESTAMPTZ, replacement + ); + INSERT INTO aether_identity.device_grant_digest_reservations( + digest_algorithm, digest_encoded, digest_key_version, grant_id, digest_kind + ) VALUES + ( + replacement#>>'{deviceCodeDigest,algorithm}', + replacement#>>'{deviceCodeDigest,encoded}', + COALESCE(replacement#>>'{deviceCodeDigest,keyVersion}', ''), + replacement->>'id', + 'device' + ), + ( + replacement#>>'{userCodeDigest,algorithm}', + replacement#>>'{userCodeDigest,encoded}', + COALESCE(replacement#>>'{userCodeDigest,keyVersion}', ''), + replacement->>'id', + 'user' + ); + ELSE + IF NOT FOUND THEN RAISE EXCEPTION 'device grant not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'device grant version conflict' USING ERRCODE = 'A0002'; + END IF; + IF (replacement->>'version')::BIGINT <> stored.version + 1 OR + stored.document#>'{deviceCodeDigest}' <> replacement#>'{deviceCodeDigest}' OR + stored.document#>'{userCodeDigest}' <> replacement#>'{userCodeDigest}' OR + stored.document->>'clientId' <> replacement->>'clientId' OR + stored.document->>'clientName' <> replacement->>'clientName' OR + NOT ((stored.document->'requestedCapabilities') @> (replacement->'requestedCapabilities') AND + (stored.document->'requestedCapabilities') <@ (replacement->'requestedCapabilities')) OR + (stored.document->>'createdAt')::TIMESTAMPTZ <> + (replacement->>'createdAt')::TIMESTAMPTZ OR + stored.expires_at <> (replacement->>'expiresAt')::TIMESTAMPTZ OR + NOT ( + (stored.state = 'pending' AND replacement->>'state' IN ( + 'pending', 'authorized', 'denied', 'expired', 'cancelled' + )) OR + (stored.state = 'authorized' AND replacement->>'state' IN ( + 'authorized', 'consumed', 'expired' + )) + ) THEN + RAISE EXCEPTION 'device grant transition invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.device_grants + SET state = replacement->>'state', version = (replacement->>'version')::BIGINT, + expires_at = (replacement->>'expiresAt')::TIMESTAMPTZ, document = replacement + WHERE id = stored.id; + END IF; + PERFORM aether_identity.record_audit(payload->'auditEvent'); + RETURN aether_identity.rpc_success('compare_and_set_device_grant', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_rotate_service_credential(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + stored aether_identity.service_credentials%ROWTYPE; + identity_document JSONB; + previous JSONB; + replacement JSONB; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'rotate_service_credential'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.service_credentials + WHERE id = payload->>'credentialId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'service credential not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'service credential version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' THEN + RAISE EXCEPTION 'service credential transition invalid' USING ERRCODE = 'A0003'; + END IF; + IF stored.document->>'expiresAt' IS NOT NULL AND + (stored.document->>'expiresAt')::TIMESTAMPTZ <= (payload->>'rotatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'service credential expired' USING ERRCODE = 'A0003'; + END IF; + IF stored.service_identity_id <> replacement->>'serviceIdentityId' THEN + RAISE EXCEPTION 'service identity changed' USING ERRCODE = 'A0003'; + END IF; + SELECT document INTO identity_document FROM aether_identity.service_identities + WHERE id = stored.service_identity_id FOR UPDATE; + IF NOT FOUND OR identity_document->>'state' <> 'active' OR + NOT ((identity_document->'capabilities') @> (replacement->'capabilities')) THEN + RAISE EXCEPTION 'service credential capabilities are invalid' USING ERRCODE = 'A0003'; + END IF; + previous := stored.document || jsonb_build_object( + 'state', 'rotated', 'rotatedToId', replacement->'id', + 'rotatedAt', payload->'rotatedAt', 'version', stored.version + 1 + ); + UPDATE aether_identity.service_credentials + SET state = 'rotated', version = stored.version + 1, document = previous + WHERE id = stored.id; + PERFORM aether_identity.insert_service_credential(replacement); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'rotate_service_credential', + jsonb_build_object('previous', previous, 'replacement', replacement, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_link_external_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; identity_document JSONB; receipt JSONB; audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'link_external_identity'); + identity_document := payload->'identity'; + receipt := payload->'replayReceipt'; + audit_event := payload->'auditEvent'; + IF identity_document->>'state' <> 'active' OR + (identity_document->>'version')::BIGINT <> 0 OR + identity_document->>'provider' <> receipt->>'provider' OR + (receipt->>'expiresAt')::TIMESTAMPTZ <= (audit_event->>'occurredAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'external identity link is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_external_replay(receipt); + INSERT INTO aether_identity.external_identities( + id, user_id, provider, subject, state, version, document + ) VALUES ( + identity_document->>'id', identity_document->>'userId', identity_document->>'provider', + identity_document->>'subject', identity_document->>'state', + (identity_document->>'version')::BIGINT, identity_document + ); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'link_external_identity', + jsonb_build_object('identity', identity_document, 'replayReceipt', receipt, 'auditEvent', audit_event) + ); +EXCEPTION + WHEN unique_violation THEN + IF EXISTS ( + SELECT 1 FROM aether_identity.external_replay_receipts + WHERE id = receipt->>'id' OR ( + provider = receipt->>'provider' AND + assertion_digest_algorithm = receipt#>>'{assertionDigest,algorithm}' AND + assertion_digest_encoded = receipt#>>'{assertionDigest,encoded}' AND + COALESCE(assertion_digest_key_version, '') = COALESCE(receipt#>>'{assertionDigest,keyVersion}', '') + ) + ) THEN + RAISE EXCEPTION 'external assertion replayed' USING ERRCODE = 'A0005'; + END IF; + RAISE; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_record_external_identity_replay(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; receipt JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'record_external_identity_replay'); + receipt := payload->'replayReceipt'; + PERFORM aether_identity.insert_external_replay(receipt); + RETURN aether_identity.rpc_success('record_external_identity_replay', receipt); +EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'external assertion replayed' USING ERRCODE = 'A0005'; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_apply_scim_mutation(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + mutation_document JSONB; + audit_event JSONB; + user_document JSONB; + membership_document JSONB; + stored_user aether_identity.users%ROWTYPE; + current_membership aether_identity.memberships%ROWTYPE; + existing_mutation JSONB; + existing_commit JSONB; + commit_result JSONB; + remaining_owners BIGINT; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'apply_scim_mutation'); + mutation_document := payload->'mutation'; + audit_event := payload->'auditEvent'; + + PERFORM pg_advisory_xact_lock( + hashtext('aether_identity.scim:' || (mutation_document->>'operationId')) + ); + SELECT scim_operations.mutation, scim_operations.commit_result INTO existing_mutation, existing_commit + FROM aether_identity.scim_operations + WHERE operation_id = mutation_document->>'operationId' FOR UPDATE; + IF FOUND THEN + IF existing_mutation <> mutation_document THEN + RAISE EXCEPTION 'SCIM operation idempotency conflict' USING ERRCODE = 'A0006'; + END IF; + RETURN aether_identity.rpc_success( + 'apply_scim_mutation', + existing_commit || jsonb_build_object('alreadyApplied', TRUE, 'auditEvent', 'null'::JSONB) + ); + END IF; + + IF mutation_document->>'type' IN ('upsert_user', 'deactivate_user') THEN + user_document := mutation_document->'user'; + membership_document := 'null'::JSONB; + IF user_document IS NULL OR user_document = 'null'::JSONB OR + (mutation_document->>'type' = 'deactivate_user' AND user_document->>'state' <> 'deactivated') THEN + RAISE EXCEPTION 'SCIM user mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored_user FROM aether_identity.users WHERE id = user_document->>'id' FOR UPDATE; + IF NOT FOUND THEN + PERFORM aether_identity.insert_user(user_document); + ELSIF (user_document->>'version')::BIGINT = stored_user.version + 1 THEN + PERFORM aether_identity.replace_user(user_document, stored_user.version); + ELSE + RAISE EXCEPTION 'SCIM user version conflict' USING ERRCODE = 'A0002'; + END IF; + ELSIF mutation_document->>'type' IN ('upsert_membership', 'remove_membership') THEN + user_document := 'null'::JSONB; + membership_document := mutation_document->'membership'; + IF membership_document IS NULL OR membership_document = 'null'::JSONB OR + (mutation_document->>'type' = 'remove_membership' AND membership_document->>'state' <> 'removed') THEN + RAISE EXCEPTION 'SCIM membership mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM 1 FROM aether_identity.organizations + WHERE id = membership_document->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'SCIM organization not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO current_membership FROM aether_identity.memberships + WHERE id = membership_document->>'id' FOR UPDATE; + IF NOT FOUND THEN + PERFORM aether_identity.insert_membership(membership_document); + ELSE + IF current_membership.organization_id <> membership_document->>'organizationId' OR + current_membership.user_id <> membership_document->>'userId' THEN + RAISE EXCEPTION 'SCIM membership identity changed' USING ERRCODE = 'A0003'; + END IF; + IF current_membership.role = 'owner' AND current_membership.state = 'active' AND + NOT (membership_document->>'role' = 'owner' AND membership_document->>'state' = 'active') THEN + SELECT COUNT(*) INTO remaining_owners FROM aether_identity.memberships + WHERE organization_id = current_membership.organization_id + AND id <> current_membership.id AND role = 'owner' AND state = 'active'; + IF remaining_owners = 0 THEN + RAISE EXCEPTION 'last owner cannot be removed' USING ERRCODE = 'A0004'; + END IF; + END IF; + IF (membership_document->>'version')::BIGINT <> current_membership.version + 1 THEN + RAISE EXCEPTION 'SCIM membership version conflict' USING ERRCODE = 'A0002'; + END IF; + UPDATE aether_identity.memberships + SET role = membership_document->>'role', state = membership_document->>'state', + version = (membership_document->>'version')::BIGINT, document = membership_document + WHERE id = current_membership.id; + END IF; + ELSE + RAISE EXCEPTION 'unsupported SCIM mutation type' USING ERRCODE = 'A0003'; + END IF; + + PERFORM aether_identity.record_audit(audit_event); + commit_result := jsonb_build_object( + 'user', user_document, + 'membership', membership_document, + 'alreadyApplied', FALSE, + 'auditEvent', audit_event + ); + INSERT INTO aether_identity.scim_operations(operation_id, provider, mutation, commit_result, occurred_at) + VALUES ( + mutation_document->>'operationId', mutation_document->>'provider', mutation_document, commit_result, + (mutation_document->>'occurredAt')::TIMESTAMPTZ + ); + RETURN aether_identity.rpc_success('apply_scim_mutation', commit_result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_device_token_family(p_family JSONB) +RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + IF p_family->>'state' <> 'active' OR + (p_family->>'version')::BIGINT <> 0 OR + length(COALESCE(p_family->>'clientId', '')) NOT BETWEEN 1 AND 200 OR + p_family->>'clientId' ~ '[^!-~]' OR + jsonb_typeof(p_family->'capabilities') IS DISTINCT FROM 'array' OR + jsonb_array_length(p_family->'capabilities') = 0 OR + (p_family->>'expiresAt')::TIMESTAMPTZ <= (p_family->>'createdAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'new device token family is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.device_token_families( + id, device_grant_id, user_id, organization_id, state, version, expires_at, document + ) VALUES ( + p_family->>'id', p_family->>'deviceGrantId', p_family->>'userId', + p_family->>'organizationId', p_family->>'state', (p_family->>'version')::BIGINT, + (p_family->>'expiresAt')::TIMESTAMPTZ, p_family + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_device_access_token(p_token JSONB) +RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + IF p_token->>'state' <> 'active' OR + (p_token->>'version')::BIGINT <> 0 OR + p_token->>'publicSelector' !~ '^[A-Za-z0-9_-]{6,64}$' OR + (p_token->>'expiresAt')::TIMESTAMPTZ <= (p_token->>'createdAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'new device access token is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.device_token_credential_reservations( + token_kind, token_id, public_selector, digest_algorithm, digest_encoded, digest_key_version + ) VALUES ( + 'access', p_token->>'id', p_token->>'publicSelector', + p_token#>>'{secretDigest,algorithm}', p_token#>>'{secretDigest,encoded}', + COALESCE(p_token#>>'{secretDigest,keyVersion}', '') + ); + INSERT INTO aether_identity.device_access_tokens( + id, family_id, public_selector, + secret_digest_algorithm, secret_digest_encoded, secret_digest_key_version, + state, version, expires_at, document + ) VALUES ( + p_token->>'id', p_token->>'familyId', p_token->>'publicSelector', + p_token#>>'{secretDigest,algorithm}', p_token#>>'{secretDigest,encoded}', + p_token#>>'{secretDigest,keyVersion}', p_token->>'state', + (p_token->>'version')::BIGINT, (p_token->>'expiresAt')::TIMESTAMPTZ, p_token + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.insert_device_refresh_token(p_token JSONB) +RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + IF p_token->>'state' <> 'active' OR + (p_token->>'version')::BIGINT <> 0 OR + (p_token->>'rotationCounter')::BIGINT < 0 OR + p_token->>'publicSelector' !~ '^[A-Za-z0-9_-]{6,64}$' OR + (p_token->>'expiresAt')::TIMESTAMPTZ <= (p_token->>'createdAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'new device refresh token is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.device_token_credential_reservations( + token_kind, token_id, public_selector, digest_algorithm, digest_encoded, digest_key_version + ) VALUES ( + 'refresh', p_token->>'id', p_token->>'publicSelector', + p_token#>>'{secretDigest,algorithm}', p_token#>>'{secretDigest,encoded}', + COALESCE(p_token#>>'{secretDigest,keyVersion}', '') + ); + INSERT INTO aether_identity.device_refresh_tokens( + id, family_id, public_selector, + secret_digest_algorithm, secret_digest_encoded, secret_digest_key_version, + rotation_counter, state, version, expires_at, document + ) VALUES ( + p_token->>'id', p_token->>'familyId', p_token->>'publicSelector', + p_token#>>'{secretDigest,algorithm}', p_token#>>'{secretDigest,encoded}', + p_token#>>'{secretDigest,keyVersion}', (p_token->>'rotationCounter')::BIGINT, + p_token->>'state', (p_token->>'version')::BIGINT, + (p_token->>'expiresAt')::TIMESTAMPTZ, p_token + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_device_grant_by_device_code_digest(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_device_grant_by_device_code_digest'); + SELECT document INTO result FROM aether_identity.device_grants + WHERE device_digest_algorithm = payload#>>'{digest,algorithm}' + AND device_digest_encoded = payload#>>'{digest,encoded}' + AND COALESCE(device_digest_key_version, '') = COALESCE(payload#>>'{digest,keyVersion}', ''); + RETURN aether_identity.rpc_success('find_device_grant_by_device_code_digest', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_device_grant_by_user_code_digest(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_device_grant_by_user_code_digest'); + SELECT document INTO result FROM aether_identity.device_grants + WHERE user_digest_algorithm = payload#>>'{digest,algorithm}' + AND user_digest_encoded = payload#>>'{digest,encoded}' + AND COALESCE(user_digest_key_version, '') = COALESCE(payload#>>'{digest,keyVersion}', ''); + RETURN aether_identity.rpc_success('find_device_grant_by_user_code_digest', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_device_token_family(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_device_token_family'); + SELECT document INTO result FROM aether_identity.device_token_families WHERE id = payload->>'id'; + RETURN aether_identity.rpc_success('find_device_token_family', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_device_access_token_by_selector(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_device_access_token_by_selector'); + SELECT document INTO result FROM aether_identity.device_access_tokens + WHERE public_selector = payload->>'publicSelector'; + RETURN aether_identity.rpc_success('find_device_access_token_by_selector', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_device_refresh_token_by_selector(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_device_refresh_token_by_selector'); + SELECT document INTO result FROM aether_identity.device_refresh_tokens + WHERE public_selector = payload->>'publicSelector'; + RETURN aether_identity.rpc_success('find_device_refresh_token_by_selector', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_mutate_credential(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + replacement JSONB; + audit_event JSONB; + stored aether_identity.credentials%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'mutate_credential'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.credentials + WHERE id = payload->>'credentialId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'credential not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'credential version conflict' USING ERRCODE = 'A0002'; + END IF; + IF replacement->>'id' <> stored.id OR + (replacement->>'version')::BIGINT <> stored.version + 1 OR + (stored.document - 'name' - 'state' - 'version' - 'updatedAt' - 'revokedAt' - 'revocationReasonCode') <> + (replacement - 'name' - 'state' - 'version' - 'updatedAt' - 'revokedAt' - 'revocationReasonCode') OR + (replacement->>'updatedAt')::TIMESTAMPTZ < (stored.document->>'updatedAt')::TIMESTAMPTZ OR + audit_event#>>'{target,type}' <> 'credential' OR + audit_event#>>'{target,id}' <> stored.id THEN + RAISE EXCEPTION 'credential mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + IF audit_event->>'action' = 'credential.renamed' THEN + IF replacement->>'name' = stored.document->>'name' OR + replacement->>'state' <> stored.state OR + replacement->'revokedAt' IS DISTINCT FROM stored.document->'revokedAt' OR + replacement->'revocationReasonCode' IS DISTINCT FROM stored.document->'revocationReasonCode' THEN + RAISE EXCEPTION 'credential rename is invalid' USING ERRCODE = 'A0003'; + END IF; + ELSIF audit_event->>'action' = 'credential.revoked' THEN + IF stored.state = 'revoked' OR replacement->>'state' <> 'revoked' OR + replacement->>'revokedAt' IS NULL OR COALESCE(replacement->>'revocationReasonCode', '') = '' THEN + RAISE EXCEPTION 'credential revocation is invalid' USING ERRCODE = 'A0003'; + END IF; + ELSE + RAISE EXCEPTION 'credential audit action is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.credentials + SET state = replacement->>'state', version = (replacement->>'version')::BIGINT, document = replacement + WHERE id = stored.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('mutate_credential', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_exchange_device_grant(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + family_document JSONB; + access_document JSONB; + refresh_document JSONB; + audit_event JSONB; + stored aether_identity.device_grants%ROWTYPE; + consumed JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'exchange_device_grant'); + family_document := payload->'family'; + access_document := payload->'accessToken'; + refresh_document := payload->'refreshToken'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.device_grants + WHERE id = payload->>'deviceGrantId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device grant not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedDeviceGrantVersion')::BIGINT THEN + RAISE EXCEPTION 'device grant version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'authorized' OR stored.expires_at <= (payload->>'exchangedAt')::TIMESTAMPTZ OR + family_document->>'deviceGrantId' <> stored.id OR + family_document->>'clientId' IS DISTINCT FROM stored.document->>'clientId' OR + family_document->>'userId' IS DISTINCT FROM stored.document->>'userId' OR + family_document->>'organizationId' IS DISTINCT FROM stored.document->>'organizationId' OR + NOT ((family_document->'capabilities') @> (stored.document->'approvedCapabilities') AND + (family_document->'capabilities') <@ (stored.document->'approvedCapabilities')) OR + (family_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'exchangedAt')::TIMESTAMPTZ OR + access_document->>'familyId' <> family_document->>'id' OR + refresh_document->>'familyId' <> family_document->>'id' OR + (refresh_document->>'rotationCounter')::BIGINT <> 0 OR + (access_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'exchangedAt')::TIMESTAMPTZ OR + (refresh_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'exchangedAt')::TIMESTAMPTZ OR + (access_document->>'expiresAt')::TIMESTAMPTZ > (family_document->>'expiresAt')::TIMESTAMPTZ OR + (refresh_document->>'expiresAt')::TIMESTAMPTZ > (family_document->>'expiresAt')::TIMESTAMPTZ OR + audit_event->>'action' <> 'device_token.issued' THEN + RAISE EXCEPTION 'device grant exchange is invalid' USING ERRCODE = 'A0003'; + END IF; + consumed := stored.document || jsonb_build_object( + 'state', 'consumed', 'version', stored.version + 1, + 'consumedAt', to_jsonb((payload->>'exchangedAt')::TIMESTAMPTZ) + ); + UPDATE aether_identity.device_grants + SET state = 'consumed', version = stored.version + 1, document = consumed + WHERE id = stored.id; + PERFORM aether_identity.insert_device_token_family(family_document); + PERFORM aether_identity.insert_device_access_token(access_document); + PERFORM aether_identity.insert_device_refresh_token(refresh_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'exchange_device_grant', + jsonb_build_object( + 'deviceGrant', consumed, 'family', family_document, 'accessToken', access_document, + 'refreshToken', refresh_document, 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_rotate_device_refresh_token(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + access_document JSONB; + refresh_document JSONB; + audit_event JSONB; + family_id TEXT; + family aether_identity.device_token_families%ROWTYPE; + previous aether_identity.device_refresh_tokens%ROWTYPE; + rotated JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'rotate_device_refresh_token'); + access_document := payload->'replacementAccessToken'; + refresh_document := payload->'replacementRefreshToken'; + audit_event := payload->'auditEvent'; + SELECT device_refresh_tokens.family_id INTO family_id + FROM aether_identity.device_refresh_tokens + WHERE id = payload->>'refreshTokenId'; + IF NOT FOUND THEN RAISE EXCEPTION 'device refresh token not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO family FROM aether_identity.device_token_families + WHERE id = family_id FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device token family not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO previous FROM aether_identity.device_refresh_tokens + WHERE id = payload->>'refreshTokenId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device refresh token not found' USING ERRCODE = 'A0012'; END IF; + IF previous.version <> (payload->>'expectedRefreshTokenVersion')::BIGINT OR + family.version <> (payload->>'expectedFamilyVersion')::BIGINT THEN + RAISE EXCEPTION 'device token version conflict' USING ERRCODE = 'A0002'; + END IF; + IF previous.family_id <> family.id OR previous.state <> 'active' OR + previous.expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ OR + family.state <> 'active' OR family.expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ OR + access_document->>'familyId' <> family.id OR refresh_document->>'familyId' <> family.id OR + (refresh_document->>'rotationCounter')::BIGINT <> previous.rotation_counter + 1 OR + (access_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'rotatedAt')::TIMESTAMPTZ OR + (refresh_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'rotatedAt')::TIMESTAMPTZ OR + (access_document->>'expiresAt')::TIMESTAMPTZ > family.expires_at OR + (refresh_document->>'expiresAt')::TIMESTAMPTZ > family.expires_at OR + audit_event->>'action' <> 'device_token.refreshed' THEN + RAISE EXCEPTION 'device refresh rotation is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_device_access_token(access_document); + PERFORM aether_identity.insert_device_refresh_token(refresh_document); + rotated := previous.document || jsonb_build_object( + 'state', 'rotated', 'version', previous.version + 1, + 'rotatedToId', refresh_document->'id', + 'consumedAt', to_jsonb((payload->>'rotatedAt')::TIMESTAMPTZ) + ); + UPDATE aether_identity.device_refresh_tokens + SET state = 'rotated', version = previous.version + 1, document = rotated + WHERE id = previous.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'rotate_device_refresh_token', + jsonb_build_object( + 'family', family.document, 'previousRefreshToken', rotated, + 'accessToken', access_document, 'refreshToken', refresh_document, + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_revoke_device_token_family(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + audit_event JSONB; + family aether_identity.device_token_families%ROWTYPE; + revoked_family JSONB; + access_ids JSONB; + refresh_ids JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'revoke_device_token_family'); + audit_event := payload->'auditEvent'; + SELECT * INTO family FROM aether_identity.device_token_families + WHERE id = payload->>'familyId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device token family not found' USING ERRCODE = 'A0012'; END IF; + IF family.version <> (payload->>'expectedFamilyVersion')::BIGINT THEN + RAISE EXCEPTION 'device token family version conflict' USING ERRCODE = 'A0002'; + END IF; + IF family.state <> 'active' OR COALESCE(payload->>'reasonCode', '') = '' OR + length(payload->>'reasonCode') > 200 OR + audit_event->>'action' <> (CASE WHEN COALESCE((payload->>'replayDetected')::BOOLEAN, FALSE) + THEN 'device_token.replay_detected' ELSE 'device_token.revoked' END) THEN + RAISE EXCEPTION 'device token family revocation is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) INTO access_ids + FROM aether_identity.device_access_tokens WHERE family_id = family.id AND state = 'active'; + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) INTO refresh_ids + FROM aether_identity.device_refresh_tokens WHERE family_id = family.id AND state = 'active'; + UPDATE aether_identity.device_access_tokens + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', 'version', version + 1, + 'revokedAt', to_jsonb((payload->>'revokedAt')::TIMESTAMPTZ) + ) + WHERE family_id = family.id AND state = 'active'; + UPDATE aether_identity.device_refresh_tokens + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', 'version', version + 1, + 'revokedAt', to_jsonb((payload->>'revokedAt')::TIMESTAMPTZ) + ) + WHERE family_id = family.id AND state = 'active'; + revoked_family := family.document || jsonb_build_object( + 'state', 'revoked', 'version', family.version + 1, + 'revokedAt', to_jsonb((payload->>'revokedAt')::TIMESTAMPTZ), + 'revocationReasonCode', payload->'reasonCode' + ); + UPDATE aether_identity.device_token_families + SET state = 'revoked', version = family.version + 1, document = revoked_family + WHERE id = family.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'revoke_device_token_family', + jsonb_build_object( + 'family', revoked_family, 'revokedAccessTokenIds', access_ids, + 'revokedRefreshTokenIds', refresh_ids, 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_organization_by_slug(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_organization_by_slug'); + SELECT document INTO result FROM aether_identity.organizations WHERE slug = payload->>'slug'; + RETURN aether_identity.rpc_success('find_organization_by_slug', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_organizations_for_user(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_organizations_for_user'); + SELECT COALESCE(jsonb_agg(o.document ORDER BY o.id), '[]'::JSONB) INTO result + FROM aether_identity.memberships m + JOIN aether_identity.organizations o ON o.id = m.organization_id + WHERE m.user_id = payload->>'userId' AND m.state = 'active' AND o.state = 'active'; + RETURN aether_identity.rpc_success('list_organizations_for_user', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_memberships_for_organization(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_memberships_for_organization'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.memberships WHERE organization_id = payload->>'organizationId'; + RETURN aether_identity.rpc_success('list_memberships_for_organization', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_invitation_by_token_digest(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_invitation_by_token_digest'); + SELECT document INTO result FROM aether_identity.invitations + WHERE token_digest_algorithm = payload#>>'{digest,algorithm}' + AND token_digest_encoded = payload#>>'{digest,encoded}' + AND COALESCE(token_digest_key_version, '') = COALESCE(payload#>>'{digest,keyVersion}', ''); + RETURN aether_identity.rpc_success('find_invitation_by_token_digest', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_invitations_for_organization(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_invitations_for_organization'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.invitations WHERE organization_id = payload->>'organizationId'; + RETURN aether_identity.rpc_success('list_invitations_for_organization', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_service_identities_for_organization(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_service_identities_for_organization'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.service_identities WHERE organization_id = payload->>'organizationId'; + RETURN aether_identity.rpc_success('list_service_identities_for_organization', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_service_credentials_for_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_service_credentials_for_identity'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.service_credentials WHERE service_identity_id = payload->>'serviceIdentityId'; + RETURN aether_identity.rpc_success('list_service_credentials_for_identity', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_organization(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; organization_document JSONB; owner_document JSONB; audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_organization'); + organization_document := payload->'organization'; + owner_document := payload->'ownerMembership'; + audit_event := payload->'auditEvent'; + IF organization_document->>'state' <> 'active' OR (organization_document->>'version')::BIGINT <> 0 OR + owner_document->>'state' <> 'active' OR owner_document->>'role' <> 'owner' OR + (owner_document->>'version')::BIGINT <> 0 OR + owner_document->>'organizationId' <> organization_document->>'id' OR + audit_event->>'action' <> 'organization.created' OR + audit_event#>>'{target,type}' <> 'organization' OR + audit_event#>>'{target,id}' <> organization_document->>'id' THEN + RAISE EXCEPTION 'organization creation is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.organizations(id, slug, state, version, document) + VALUES ( + organization_document->>'id', organization_document->>'slug', organization_document->>'state', + (organization_document->>'version')::BIGINT, organization_document + ); + PERFORM aether_identity.insert_membership(owner_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'create_organization', + jsonb_build_object( + 'organization', organization_document, 'ownerMembership', owner_document, 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_mutate_organization(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; replacement JSONB; audit_event JSONB; stored aether_identity.organizations%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'mutate_organization'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.organizations + WHERE id = payload->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'organization not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'organization version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state = 'deleted' OR replacement->>'id' <> stored.id OR + replacement->>'slug' <> stored.slug OR (replacement->>'version')::BIGINT <> stored.version + 1 OR + replacement->'createdAt' IS DISTINCT FROM stored.document->'createdAt' OR + audit_event#>>'{target,type}' <> 'organization' OR audit_event#>>'{target,id}' <> stored.id OR + audit_event->>'action' <> (CASE WHEN replacement->>'state' = 'deleted' + THEN 'organization.deleted' ELSE 'organization.changed' END) THEN + RAISE EXCEPTION 'organization mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.organizations + SET state = replacement->>'state', version = (replacement->>'version')::BIGINT, document = replacement + WHERE id = stored.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('mutate_organization', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_invitation(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; invitation_document JSONB; audit_event JSONB; organization_state TEXT; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_invitation'); + invitation_document := payload->'invitation'; + audit_event := payload->'auditEvent'; + SELECT state INTO organization_state FROM aether_identity.organizations + WHERE id = invitation_document->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'organization not found' USING ERRCODE = 'A0012'; END IF; + IF organization_state <> 'active' OR invitation_document->>'state' <> 'pending' OR + (invitation_document->>'version')::BIGINT <> 0 OR + (invitation_document->>'expiresAt')::TIMESTAMPTZ <= (invitation_document->>'createdAt')::TIMESTAMPTZ OR + audit_event->>'action' <> 'invitation.created' OR + audit_event#>>'{target,type}' <> 'invitation' OR + audit_event#>>'{target,id}' <> invitation_document->>'id' THEN + RAISE EXCEPTION 'invitation creation is invalid' USING ERRCODE = 'A0003'; + END IF; + IF invitation_document->>'invitedByUserId' IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM aether_identity.users WHERE id = invitation_document->>'invitedByUserId' + ) THEN + RAISE EXCEPTION 'inviting user not found' USING ERRCODE = 'A0012'; + END IF; + INSERT INTO aether_identity.invitations( + id, organization_id, email, + token_digest_algorithm, token_digest_encoded, token_digest_key_version, + state, version, document + ) VALUES ( + invitation_document->>'id', invitation_document->>'organizationId', invitation_document->>'email', + invitation_document#>>'{tokenDigest,algorithm}', invitation_document#>>'{tokenDigest,encoded}', + invitation_document#>>'{tokenDigest,keyVersion}', invitation_document->>'state', + (invitation_document->>'version')::BIGINT, invitation_document + ); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('create_invitation', invitation_document); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_mutate_invitation(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; replacement JSONB; audit_event JSONB; stored aether_identity.invitations%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'mutate_invitation'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.invitations + WHERE id = payload->>'invitationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'invitation not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'invitation version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'pending' OR replacement->>'id' <> stored.id OR + replacement->>'organizationId' <> stored.organization_id OR replacement->>'email' <> stored.email OR + replacement->>'role' <> stored.document->>'role' OR + replacement->'tokenDigest' IS DISTINCT FROM stored.document->'tokenDigest' OR + replacement->'createdAt' IS DISTINCT FROM stored.document->'createdAt' OR + replacement->'expiresAt' IS DISTINCT FROM stored.document->'expiresAt' OR + replacement->>'state' <> 'revoked' OR replacement->>'revokedAt' IS NULL OR + (replacement->>'version')::BIGINT <> stored.version + 1 OR + audit_event->>'action' <> 'invitation.revoked' OR + audit_event#>>'{target,type}' <> 'invitation' OR audit_event#>>'{target,id}' <> stored.id THEN + RAISE EXCEPTION 'invitation mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.invitations + SET state = 'revoked', version = (replacement->>'version')::BIGINT, document = replacement + WHERE id = stored.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('mutate_invitation', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_service_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; identity_document JSONB; credential_document JSONB; audit_event JSONB; organization_state TEXT; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_service_identity'); + identity_document := payload->'identity'; + credential_document := payload->'initialCredential'; + audit_event := payload->'auditEvent'; + SELECT state INTO organization_state FROM aether_identity.organizations + WHERE id = identity_document->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'organization not found' USING ERRCODE = 'A0012'; END IF; + IF organization_state <> 'active' OR identity_document->>'state' <> 'active' OR + (identity_document->>'version')::BIGINT <> 0 OR credential_document->>'state' <> 'active' OR + (credential_document->>'version')::BIGINT <> 0 OR + credential_document->>'serviceIdentityId' <> identity_document->>'id' OR + NOT ((identity_document->'capabilities') @> (credential_document->'capabilities')) OR + credential_document->>'expiresAt' IS NULL OR + (credential_document->>'expiresAt')::TIMESTAMPTZ <= (identity_document->>'createdAt')::TIMESTAMPTZ OR + audit_event->>'action' <> 'service_identity.created' OR + audit_event#>>'{target,type}' <> 'service_identity' OR + audit_event#>>'{target,id}' <> identity_document->>'id' THEN + RAISE EXCEPTION 'service identity creation is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.service_identities( + id, organization_id, name, state, version, document + ) VALUES ( + identity_document->>'id', identity_document->>'organizationId', identity_document->>'name', + identity_document->>'state', (identity_document->>'version')::BIGINT, identity_document + ); + PERFORM aether_identity.insert_service_credential(credential_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'create_service_identity', + jsonb_build_object( + 'identity', identity_document, 'initialCredential', credential_document, 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_mutate_service_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; replacement JSONB; audit_event JSONB; stored aether_identity.service_identities%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'mutate_service_identity'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.service_identities + WHERE id = payload->>'serviceIdentityId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'service identity not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'service identity version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state = 'revoked' OR replacement->>'id' <> stored.id OR + replacement->>'organizationId' <> stored.organization_id OR + replacement->'createdAt' IS DISTINCT FROM stored.document->'createdAt' OR + replacement->'updatedAt' IS DISTINCT FROM payload->'changedAt' OR + (replacement->>'version')::BIGINT <> stored.version + 1 OR + audit_event#>>'{target,type}' <> 'service_identity' OR audit_event#>>'{target,id}' <> stored.id OR + audit_event->>'action' <> (CASE WHEN replacement->>'state' = 'revoked' + THEN 'service_identity.revoked' ELSE 'service_identity.changed' END) THEN + RAISE EXCEPTION 'service identity mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.service_identities + SET name = replacement->>'name', state = replacement->>'state', + version = (replacement->>'version')::BIGINT, document = replacement + WHERE id = stored.id; + IF replacement->>'state' = 'revoked' THEN + UPDATE aether_identity.service_credentials + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', 'version', version + 1, + 'revokedAt', payload->'changedAt' + ) + WHERE service_identity_id = stored.id AND state IN ('active', 'rotated'); + END IF; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('mutate_service_identity', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_service_credential(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; credential_document JSONB; audit_event JSONB; identity_document JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_service_credential'); + credential_document := payload->'credential'; + audit_event := payload->'auditEvent'; + SELECT document INTO identity_document FROM aether_identity.service_identities + WHERE id = credential_document->>'serviceIdentityId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'service identity not found' USING ERRCODE = 'A0012'; END IF; + IF identity_document->>'state' <> 'active' OR credential_document->>'state' <> 'active' OR + (credential_document->>'version')::BIGINT <> 0 OR + NOT ((identity_document->'capabilities') @> (credential_document->'capabilities')) OR + audit_event->>'action' <> 'service_credential.created' OR + audit_event#>>'{target,type}' <> 'service_credential' OR + audit_event#>>'{target,id}' <> credential_document->>'id' THEN + RAISE EXCEPTION 'service credential creation is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_service_credential(credential_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('create_service_credential', credential_document); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_revoke_service_credential(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; audit_event JSONB; stored aether_identity.service_credentials%ROWTYPE; revoked JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'revoke_service_credential'); + audit_event := payload->'auditEvent'; + SELECT * INTO stored FROM aether_identity.service_credentials + WHERE id = payload->>'credentialId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'service credential not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'service credential version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state NOT IN ('active', 'rotated') OR + (payload->>'revokedAt')::TIMESTAMPTZ < (stored.document->>'createdAt')::TIMESTAMPTZ OR + audit_event->>'action' <> 'service_credential.revoked' OR + audit_event#>>'{target,type}' <> 'service_credential' OR + audit_event#>>'{target,id}' <> stored.id THEN + RAISE EXCEPTION 'service credential revocation is invalid' USING ERRCODE = 'A0003'; + END IF; + revoked := stored.document || jsonb_build_object( + 'state', 'revoked', 'version', stored.version + 1, 'revokedAt', payload->'revokedAt' + ); + UPDATE aether_identity.service_credentials + SET state = 'revoked', version = stored.version + 1, document = revoked + WHERE id = stored.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success('revoke_service_credential', revoked); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_recovery_codes_for_user(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_recovery_codes_for_user'); + SELECT COALESCE(jsonb_agg(document ORDER BY id), '[]'::JSONB) INTO result + FROM aether_identity.recovery_codes WHERE user_id = payload->>'userId'; + RETURN aether_identity.rpc_success('list_recovery_codes_for_user', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_append_audit_event(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; event_document JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'append_audit_event'); + event_document := payload->'event'; + PERFORM aether_identity.record_audit(event_document); + RETURN aether_identity.rpc_success('append_audit_event', event_document); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_bootstrap_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + receipt JSONB; + user_document JSONB; + organization_document JSONB; + membership_document JSONB; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'bootstrap_identity'); + receipt := payload->'bootstrapSecretDigest'; + user_document := payload->'user'; + organization_document := payload->'organization'; + membership_document := payload->'ownerMembership'; + audit_event := payload->'auditEvent'; + + -- Bootstrap is a first-install operation. Blocking concurrent identity writers here ensures + -- the emptiness decision, receipt consumption, first owner, and audit event commit together. + LOCK TABLE + aether_identity.bootstrap_receipts, + aether_identity.users, + aether_identity.credentials, + aether_identity.sessions, + aether_identity.organizations, + aether_identity.memberships, + aether_identity.invitations, + aether_identity.service_identities, + aether_identity.service_credentials, + aether_identity.external_identities, + aether_identity.challenges, + aether_identity.recovery_codes, + aether_identity.device_grants, + aether_identity.device_token_families, + aether_identity.device_access_tokens, + aether_identity.device_refresh_tokens, + aether_identity.audit_events + IN SHARE ROW EXCLUSIVE MODE; + + IF EXISTS (SELECT 1 FROM aether_identity.bootstrap_receipts) OR + EXISTS (SELECT 1 FROM aether_identity.users) OR + EXISTS (SELECT 1 FROM aether_identity.credentials) OR + EXISTS (SELECT 1 FROM aether_identity.sessions) OR + EXISTS (SELECT 1 FROM aether_identity.organizations) OR + EXISTS (SELECT 1 FROM aether_identity.memberships) OR + EXISTS (SELECT 1 FROM aether_identity.invitations) OR + EXISTS (SELECT 1 FROM aether_identity.service_identities) OR + EXISTS (SELECT 1 FROM aether_identity.service_credentials) OR + EXISTS (SELECT 1 FROM aether_identity.external_identities) OR + EXISTS (SELECT 1 FROM aether_identity.challenges) OR + EXISTS (SELECT 1 FROM aether_identity.recovery_codes) OR + EXISTS (SELECT 1 FROM aether_identity.device_grants) OR + EXISTS (SELECT 1 FROM aether_identity.device_token_families) OR + EXISTS (SELECT 1 FROM aether_identity.device_access_tokens) OR + EXISTS (SELECT 1 FROM aether_identity.device_refresh_tokens) THEN + RAISE EXCEPTION 'identity is already bootstrapped' USING ERRCODE = 'A0013'; + END IF; + + IF jsonb_typeof(receipt) IS DISTINCT FROM 'object' OR + receipt->>'algorithm' IS DISTINCT FROM 'sha256' OR + COALESCE(length(receipt->>'encoded'), 0) NOT BETWEEN 1 AND 1024 OR + receipt->>'keyVersion' IS NOT NULL OR + user_document->>'state' IS DISTINCT FROM 'active' OR + (user_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + organization_document->>'state' IS DISTINCT FROM 'active' OR + (organization_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + membership_document->>'state' IS DISTINCT FROM 'active' OR + membership_document->>'role' IS DISTINCT FROM 'owner' OR + (membership_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + membership_document->>'userId' IS DISTINCT FROM user_document->>'id' OR + membership_document->>'organizationId' IS DISTINCT FROM organization_document->>'id' OR + audit_event->>'action' IS DISTINCT FROM 'identity.bootstrapped' OR + audit_event#>>'{target,type}' IS DISTINCT FROM 'user' OR + audit_event#>>'{target,id}' IS DISTINCT FROM user_document->>'id' OR + (audit_event->>'organizationId' IS NOT NULL AND + audit_event->>'organizationId' IS DISTINCT FROM organization_document->>'id') THEN + RAISE EXCEPTION 'identity bootstrap payload is invalid' USING ERRCODE = 'A0003'; + END IF; + + INSERT INTO aether_identity.bootstrap_receipts( + singleton, digest_algorithm, digest_encoded, consumed_at + ) VALUES ( + TRUE, receipt->>'algorithm', receipt->>'encoded', + (audit_event->>'occurredAt')::TIMESTAMPTZ + ); + PERFORM aether_identity.insert_user(user_document); + INSERT INTO aether_identity.organizations(id, slug, state, version, document) + VALUES ( + organization_document->>'id', organization_document->>'slug', + organization_document->>'state', (organization_document->>'version')::BIGINT, + organization_document + ); + PERFORM aether_identity.insert_membership(membership_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'bootstrap_identity', + jsonb_build_object( + 'user', user_document, + 'organization', organization_document, + 'ownerMembership', membership_document, + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_redeem_administrative_recovery_ticket(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + stored_challenge aether_identity.challenges%ROWTYPE; + session_document JSONB; + audit_event JSONB; + consumed JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'redeem_administrative_recovery_ticket'); + session_document := payload->'recoverySession'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored_challenge FROM aether_identity.challenges + WHERE id = payload->>'challengeId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery ticket not found' USING ERRCODE = 'A0012'; END IF; + IF stored_challenge.version <> (payload->>'expectedChallengeVersion')::BIGINT THEN + RAISE EXCEPTION 'recovery ticket version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_challenge.state <> 'pending' THEN + RAISE EXCEPTION 'recovery ticket is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored_challenge.expires_at <= (payload->>'redeemedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'recovery ticket expired' USING ERRCODE = 'A0008'; + END IF; + IF stored_challenge.purpose <> 'account_recovery' OR stored_challenge.user_id IS NULL OR + session_document->>'userId' <> stored_challenge.user_id OR + session_document->>'assurance' <> 'recovery' OR + session_document->>'familyId' <> session_document->>'id' OR + session_document->'rotatedFromId' <> 'null'::JSONB OR + (session_document->>'rotationCounter')::BIGINT <> 0 OR + (session_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'redeemedAt')::TIMESTAMPTZ OR + audit_event->>'action' <> 'recovery.admin_ticket_used' THEN + RAISE EXCEPTION 'administrative recovery redemption is invalid' USING ERRCODE = 'A0003'; + END IF; + consumed := aether_identity.consume_challenge_model( + stored_challenge.id, + stored_challenge.version, + 'consumed', + (payload->>'redeemedAt')::TIMESTAMPTZ + ); + PERFORM aether_identity.insert_session(session_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'redeem_administrative_recovery_ticket', + jsonb_build_object('challenge', consumed, 'recoverySession', session_document, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_complete_recovery_enrollment(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + credential_document JSONB; + replacement_codes JSONB; + audit_event JSONB; + stored_challenge aether_identity.challenges%ROWTYPE; + stored_user aether_identity.users%ROWTYPE; + recovery_session aether_identity.sessions%ROWTYPE; + current_generation BIGINT; + expected_generation BIGINT; + updated_user JSONB; + consumed_challenge JSONB; + revoked_session_ids JSONB; + code_document JSONB; + code_count INTEGER; + unique_id_count INTEGER; + unique_selector_count INTEGER; + unique_digest_count INTEGER; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'complete_recovery_enrollment'); + credential_document := payload->'credential'; + replacement_codes := payload->'replacementRecoveryCodes'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored_challenge FROM aether_identity.challenges + WHERE id = payload->>'challengeId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery enrollment challenge not found' USING ERRCODE = 'A0012'; END IF; + IF stored_challenge.version <> (payload->>'expectedChallengeVersion')::BIGINT THEN + RAISE EXCEPTION 'recovery enrollment challenge version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_challenge.state <> 'pending' THEN + RAISE EXCEPTION 'recovery enrollment challenge is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored_challenge.expires_at <= (payload->>'completedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'recovery enrollment challenge expired' USING ERRCODE = 'A0008'; + END IF; + IF stored_challenge.purpose <> 'webauthn_registration' OR + stored_challenge.user_id IS DISTINCT FROM credential_document->>'userId' THEN + RAISE EXCEPTION 'recovery enrollment challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = credential_document->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery user not found' USING ERRCODE = 'A0012'; END IF; + IF stored_user.version <> (payload->>'expectedUserVersion')::BIGINT OR + stored_user.session_epoch <> (payload->>'expectedSessionEpoch')::BIGINT THEN + RAISE EXCEPTION 'recovery user version conflict' USING ERRCODE = 'A0002'; + END IF; + IF (payload->>'newSessionEpoch')::BIGINT <> stored_user.session_epoch + 1 THEN + RAISE EXCEPTION 'recovery session epoch is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO recovery_session FROM aether_identity.sessions + WHERE id = payload->>'recoverySessionId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery session not found' USING ERRCODE = 'A0012'; END IF; + IF recovery_session.version <> (payload->>'expectedRecoverySessionVersion')::BIGINT THEN + RAISE EXCEPTION 'recovery session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF recovery_session.state <> 'active' THEN + RAISE EXCEPTION 'recovery session is not active' USING ERRCODE = 'A0009'; + END IF; + IF recovery_session.idle_expires_at <= (payload->>'completedAt')::TIMESTAMPTZ OR + recovery_session.absolute_expires_at <= (payload->>'completedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'recovery session expired' USING ERRCODE = 'A0010'; + END IF; + IF recovery_session.user_id <> stored_user.id OR + recovery_session.user_session_epoch <> stored_user.session_epoch OR + recovery_session.document->>'assurance' <> 'recovery' THEN + RAISE EXCEPTION 'recovery session is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM 1 FROM aether_identity.recovery_codes WHERE user_id = stored_user.id FOR UPDATE; + SELECT MAX(generation) INTO current_generation + FROM aether_identity.recovery_codes WHERE user_id = stored_user.id; + expected_generation := CASE + WHEN payload->'expectedRecoveryGeneration' IS NULL OR + payload->'expectedRecoveryGeneration' = 'null'::JSONB THEN NULL + ELSE (payload->>'expectedRecoveryGeneration')::BIGINT + END; + IF current_generation IS DISTINCT FROM expected_generation THEN + RAISE EXCEPTION 'recovery generation conflict' USING ERRCODE = 'A0002'; + END IF; + IF (payload->>'newRecoveryGeneration')::BIGINT <> COALESCE(expected_generation + 1, 0) OR + jsonb_typeof(replacement_codes) IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'recovery generation replacement is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT COUNT(*), COUNT(DISTINCT value->>'id'), COUNT(DISTINCT value->>'publicSelector'), + COUNT(DISTINCT value->'secretDigest') + INTO code_count, unique_id_count, unique_selector_count, unique_digest_count + FROM jsonb_array_elements(replacement_codes); + IF code_count <> 10 OR unique_id_count <> 10 OR unique_selector_count <> 10 OR unique_digest_count <> 10 OR + EXISTS ( + SELECT 1 FROM jsonb_array_elements(replacement_codes) code + WHERE code->>'userId' <> stored_user.id OR + (code->>'generation')::BIGINT <> (payload->>'newRecoveryGeneration')::BIGINT OR + (code->>'version')::BIGINT <> 0 OR code->>'state' <> 'active' + ) OR credential_document->>'state' <> 'active' OR + (credential_document->>'version')::BIGINT <> 0 OR + audit_event->>'action' <> 'recovery.enrollment_completed' THEN + RAISE EXCEPTION 'recovery enrollment replacement is invalid' USING ERRCODE = 'A0003'; + END IF; + consumed_challenge := aether_identity.consume_challenge_model( + stored_challenge.id, + stored_challenge.version, + 'consumed', + (payload->>'completedAt')::TIMESTAMPTZ + ); + PERFORM aether_identity.insert_credential(credential_document); + updated_user := stored_user.document || jsonb_build_object( + 'sessionEpoch', (payload->>'newSessionEpoch')::BIGINT, + 'version', stored_user.version + 1, + 'updatedAt', payload->'completedAt' + ); + UPDATE aether_identity.users + SET session_epoch = (payload->>'newSessionEpoch')::BIGINT, + version = stored_user.version + 1, + document = updated_user + WHERE id = stored_user.id; + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) INTO revoked_session_ids + FROM aether_identity.sessions WHERE user_id = stored_user.id AND state = 'active'; + UPDATE aether_identity.sessions + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', 'version', version + 1, + 'revokedAt', payload->'completedAt', + 'revocationReasonCode', 'recovery_enrollment_completed' + ) + WHERE user_id = stored_user.id AND state = 'active'; + UPDATE aether_identity.recovery_codes + SET state = 'revoked', version = version + 1, + document = document || jsonb_build_object('state', 'revoked', 'version', version + 1) + WHERE user_id = stored_user.id AND state = 'active'; + FOR code_document IN SELECT value FROM jsonb_array_elements(replacement_codes) + LOOP + PERFORM aether_identity.insert_recovery_code(code_document); + END LOOP; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'complete_recovery_enrollment', + jsonb_build_object( + 'challenge', consumed_challenge, + 'credential', credential_document, + 'user', updated_user, + 'revokedSessionIds', revoked_session_ids, + 'recoveryGeneration', (payload->>'newRecoveryGeneration')::BIGINT, + 'recoveryCodes', replacement_codes, + 'auditEvent', audit_event + ) + ); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V002__federated_session_provenance.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V002__federated_session_provenance.sql new file mode 100644 index 0000000..1c54ba7 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V002__federated_session_provenance.sql @@ -0,0 +1,1095 @@ +ALTER TABLE aether_identity.sessions + ADD COLUMN authentication_method TEXT, + ADD COLUMN federation_organization_id TEXT, + ADD COLUMN federation_provider_key TEXT, + ADD COLUMN external_identity_id TEXT; + +-- V001 predates explicit provenance. Its passkey/recovery documents can be classified safely; +-- new federated sessions already carry all three provenance values in their canonical document. +UPDATE aether_identity.sessions + SET authentication_method = COALESCE( + document->>'authenticationMethod', + CASE WHEN document->>'assurance' = 'recovery' THEN 'recovery_code' ELSE 'passkey' END + ), + federation_organization_id = document->>'federationOrganizationId', + federation_provider_key = document->>'federationProviderKey', + external_identity_id = document->>'externalIdentityId'; + +UPDATE aether_identity.sessions + SET document = jsonb_set(document, '{authenticationMethod}', to_jsonb(authentication_method)) + WHERE document->>'authenticationMethod' IS NULL; + +ALTER TABLE aether_identity.sessions + ALTER COLUMN authentication_method SET NOT NULL, + ADD CONSTRAINT sessions_authentication_method_check CHECK ( + authentication_method IN ( + 'passkey', 'recovery_code', 'administrative_recovery', 'bootstrap', 'invitation', 'oidc', 'saml' + ) + ), + ADD CONSTRAINT sessions_authentication_method_document_check CHECK ( + document->>'authenticationMethod' = authentication_method + ), + ADD CONSTRAINT sessions_authentication_assurance_check CHECK ( + (authentication_method = 'passkey' AND document->>'assurance' IN ('passkey', 'step_up')) OR + (authentication_method IN ('recovery_code', 'administrative_recovery', 'bootstrap', 'invitation') AND + document->>'assurance' = 'recovery') OR + (authentication_method IN ('oidc', 'saml') AND document->>'assurance' = 'session') + ), + ADD CONSTRAINT sessions_federation_organization_document_check CHECK ( + (document->>'federationOrganizationId') IS NOT DISTINCT FROM federation_organization_id + ), + ADD CONSTRAINT sessions_federation_provider_document_check CHECK ( + (document->>'federationProviderKey') IS NOT DISTINCT FROM federation_provider_key + ), + ADD CONSTRAINT sessions_external_identity_document_check CHECK ( + (document->>'externalIdentityId') IS NOT DISTINCT FROM external_identity_id + ), + ADD CONSTRAINT sessions_federation_provenance_check CHECK ( + ( + authentication_method IN ('oidc', 'saml') AND + federation_organization_id IS NOT NULL AND + federation_provider_key IS NOT NULL AND + length(federation_provider_key) BETWEEN 1 AND 512 AND + federation_provider_key ~ '[^[:space:]]' AND + external_identity_id IS NOT NULL + ) OR ( + authentication_method NOT IN ('oidc', 'saml') AND + federation_organization_id IS NULL AND + federation_provider_key IS NULL AND + external_identity_id IS NULL + ) + ); + +CREATE INDEX sessions_active_federation_provider_idx + ON aether_identity.sessions ( + federation_organization_id, federation_provider_key, authentication_method, id + ) + WHERE state = 'active' AND authentication_method IN ('oidc', 'saml'); + +CREATE INDEX sessions_external_identity_idx + ON aether_identity.sessions (external_identity_id, id) + WHERE external_identity_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION aether_identity.insert_session(p_session JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + stored_user aether_identity.users%ROWTYPE; +BEGIN + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = p_session->>'userId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'session user not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_user.state <> 'active' OR + stored_user.session_epoch <> (p_session->>'userSessionEpoch')::BIGINT OR + p_session->>'state' <> 'active' OR + (p_session->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new session is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.sessions( + id, family_id, user_id, + token_digest_algorithm, token_digest_encoded, token_digest_key_version, + csrf_digest_algorithm, csrf_digest_encoded, csrf_digest_key_version, + authentication_method, federation_organization_id, federation_provider_key, + external_identity_id, state, user_session_epoch, version, + idle_expires_at, absolute_expires_at, document + ) VALUES ( + p_session->>'id', p_session->>'familyId', p_session->>'userId', + p_session#>>'{tokenDigest,algorithm}', p_session#>>'{tokenDigest,encoded}', + p_session#>>'{tokenDigest,keyVersion}', + p_session#>>'{csrfDigest,algorithm}', p_session#>>'{csrfDigest,encoded}', + p_session#>>'{csrfDigest,keyVersion}', + p_session->>'authenticationMethod', p_session->>'federationOrganizationId', + p_session->>'federationProviderKey', p_session->>'externalIdentityId', + p_session->>'state', (p_session->>'userSessionEpoch')::BIGINT, + (p_session->>'version')::BIGINT, + (p_session->>'idleExpiresAt')::TIMESTAMPTZ, + (p_session->>'absoluteExpiresAt')::TIMESTAMPTZ, p_session + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_bootstrap_identity(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + receipt JSONB; + user_document JSONB; + organization_document JSONB; + membership_document JSONB; + enrollment_session JSONB; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'bootstrap_identity'); + receipt := payload->'bootstrapSecretDigest'; + user_document := payload->'user'; + organization_document := payload->'organization'; + membership_document := payload->'ownerMembership'; + enrollment_session := payload->'enrollmentSession'; + audit_event := payload->'auditEvent'; + + LOCK TABLE + aether_identity.bootstrap_receipts, + aether_identity.users, + aether_identity.credentials, + aether_identity.sessions, + aether_identity.organizations, + aether_identity.memberships, + aether_identity.invitations, + aether_identity.service_identities, + aether_identity.service_credentials, + aether_identity.external_identities, + aether_identity.challenges, + aether_identity.recovery_codes, + aether_identity.device_grants, + aether_identity.device_token_families, + aether_identity.device_access_tokens, + aether_identity.device_refresh_tokens, + aether_identity.audit_events + IN SHARE ROW EXCLUSIVE MODE; + + IF EXISTS (SELECT 1 FROM aether_identity.bootstrap_receipts) OR + EXISTS (SELECT 1 FROM aether_identity.users) OR + EXISTS (SELECT 1 FROM aether_identity.credentials) OR + EXISTS (SELECT 1 FROM aether_identity.sessions) OR + EXISTS (SELECT 1 FROM aether_identity.organizations) OR + EXISTS (SELECT 1 FROM aether_identity.memberships) OR + EXISTS (SELECT 1 FROM aether_identity.invitations) OR + EXISTS (SELECT 1 FROM aether_identity.service_identities) OR + EXISTS (SELECT 1 FROM aether_identity.service_credentials) OR + EXISTS (SELECT 1 FROM aether_identity.external_identities) OR + EXISTS (SELECT 1 FROM aether_identity.challenges) OR + EXISTS (SELECT 1 FROM aether_identity.recovery_codes) OR + EXISTS (SELECT 1 FROM aether_identity.device_grants) OR + EXISTS (SELECT 1 FROM aether_identity.device_token_families) OR + EXISTS (SELECT 1 FROM aether_identity.device_access_tokens) OR + EXISTS (SELECT 1 FROM aether_identity.device_refresh_tokens) THEN + RAISE EXCEPTION 'identity is already bootstrapped' USING ERRCODE = 'A0013'; + END IF; + + IF jsonb_typeof(receipt) IS DISTINCT FROM 'object' OR + receipt->>'algorithm' IS DISTINCT FROM 'sha256' OR + COALESCE(length(receipt->>'encoded'), 0) NOT BETWEEN 1 AND 1024 OR + receipt->>'keyVersion' IS NOT NULL OR + user_document->>'state' IS DISTINCT FROM 'active' OR + (user_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + organization_document->>'state' IS DISTINCT FROM 'active' OR + (organization_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + membership_document->>'state' IS DISTINCT FROM 'active' OR + membership_document->>'role' IS DISTINCT FROM 'owner' OR + (membership_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + membership_document->>'userId' IS DISTINCT FROM user_document->>'id' OR + membership_document->>'organizationId' IS DISTINCT FROM organization_document->>'id' OR + enrollment_session->>'state' IS DISTINCT FROM 'active' OR + (enrollment_session->>'version')::BIGINT IS DISTINCT FROM 0 OR + enrollment_session->>'userId' IS DISTINCT FROM user_document->>'id' OR + (enrollment_session->>'userSessionEpoch')::BIGINT IS DISTINCT FROM + (user_document->>'sessionEpoch')::BIGINT OR + enrollment_session->>'assurance' IS DISTINCT FROM 'recovery' OR + enrollment_session->>'authenticationMethod' IS DISTINCT FROM 'bootstrap' OR + audit_event->>'action' IS DISTINCT FROM 'identity.bootstrapped' OR + audit_event#>>'{target,type}' IS DISTINCT FROM 'user' OR + audit_event#>>'{target,id}' IS DISTINCT FROM user_document->>'id' OR + (audit_event->>'organizationId' IS NOT NULL AND + audit_event->>'organizationId' IS DISTINCT FROM organization_document->>'id') THEN + RAISE EXCEPTION 'identity bootstrap payload is invalid' USING ERRCODE = 'A0003'; + END IF; + + INSERT INTO aether_identity.bootstrap_receipts( + singleton, digest_algorithm, digest_encoded, consumed_at + ) VALUES ( + TRUE, receipt->>'algorithm', receipt->>'encoded', + (audit_event->>'occurredAt')::TIMESTAMPTZ + ); + PERFORM aether_identity.insert_user(user_document); + INSERT INTO aether_identity.organizations(id, slug, state, version, document) + VALUES ( + organization_document->>'id', organization_document->>'slug', + organization_document->>'state', (organization_document->>'version')::BIGINT, + organization_document + ); + PERFORM aether_identity.insert_membership(membership_document); + PERFORM aether_identity.insert_session(enrollment_session); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'bootstrap_identity', + jsonb_build_object( + 'user', user_document, + 'organization', organization_document, + 'ownerMembership', membership_document, + 'enrollmentSession', enrollment_session, + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_enroll_invitation(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + payload JSONB; + user_document JSONB; + membership_document JSONB; + session_document JSONB; + audit_event JSONB; + invitation aether_identity.invitations%ROWTYPE; + organization aether_identity.organizations%ROWTYPE; + accepted_invitation JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'enroll_invitation'); + user_document := payload->'user'; + membership_document := payload->'membership'; + session_document := payload->'enrollmentSession'; + audit_event := payload->'auditEvent'; + + SELECT * INTO invitation + FROM aether_identity.invitations + WHERE id = payload->>'invitationId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'invitation not found' USING ERRCODE = 'A0012'; + END IF; + IF invitation.version <> (payload->>'expectedInvitationVersion')::BIGINT THEN + RAISE EXCEPTION 'invitation version conflict' USING ERRCODE = 'A0002'; + END IF; + IF invitation.token_digest_algorithm IS DISTINCT FROM payload#>>'{expectedTokenDigest,algorithm}' OR + invitation.token_digest_encoded IS DISTINCT FROM payload#>>'{expectedTokenDigest,encoded}' OR + COALESCE(invitation.token_digest_key_version, '') IS DISTINCT FROM + COALESCE(payload#>>'{expectedTokenDigest,keyVersion}', '') THEN + RAISE EXCEPTION 'invitation credential mismatch' USING ERRCODE = 'A0012'; + END IF; + IF invitation.state <> 'pending' OR + (invitation.document->>'expiresAt')::TIMESTAMPTZ <= (payload->>'enrolledAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'invitation is not enrollable' USING ERRCODE = 'A0003'; + END IF; + + SELECT * INTO organization + FROM aether_identity.organizations + WHERE id = invitation.organization_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'invitation organization not found' USING ERRCODE = 'A0012'; + END IF; + IF organization.state <> 'active' OR + user_document->>'state' IS DISTINCT FROM 'active' OR + (user_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + (user_document->>'sessionEpoch')::BIGINT IS DISTINCT FROM 0 OR + lower(user_document->>'primaryEmail') IS DISTINCT FROM lower(invitation.email) OR + user_document->>'createdAt' IS DISTINCT FROM payload->>'enrolledAt' OR + user_document->>'updatedAt' IS DISTINCT FROM payload->>'enrolledAt' OR + user_document->>'activatedAt' IS DISTINCT FROM payload->>'enrolledAt' OR + membership_document->>'organizationId' IS DISTINCT FROM invitation.organization_id OR + membership_document->>'userId' IS DISTINCT FROM user_document->>'id' OR + membership_document->>'role' IS DISTINCT FROM invitation.document->>'role' OR + membership_document->>'state' IS DISTINCT FROM 'active' OR + (membership_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + membership_document->>'createdAt' IS DISTINCT FROM payload->>'enrolledAt' OR + membership_document->>'updatedAt' IS DISTINCT FROM payload->>'enrolledAt' OR + session_document->>'userId' IS DISTINCT FROM user_document->>'id' OR + session_document->>'familyId' IS DISTINCT FROM session_document->>'id' OR + session_document->>'state' IS DISTINCT FROM 'active' OR + (session_document->>'version')::BIGINT IS DISTINCT FROM 0 OR + (session_document->>'rotationCounter')::BIGINT IS DISTINCT FROM 0 OR + (session_document->>'userSessionEpoch')::BIGINT IS DISTINCT FROM 0 OR + session_document->>'assurance' IS DISTINCT FROM 'recovery' OR + session_document->>'authenticationMethod' IS DISTINCT FROM 'invitation' OR + session_document->>'createdAt' IS DISTINCT FROM payload->>'enrolledAt' OR + session_document->>'authenticatedAt' IS DISTINCT FROM payload->>'enrolledAt' OR + (session_document->>'idleExpiresAt')::TIMESTAMPTZ IS DISTINCT FROM + (payload->>'enrolledAt')::TIMESTAMPTZ + INTERVAL '15 minutes' OR + (session_document->>'absoluteExpiresAt')::TIMESTAMPTZ IS DISTINCT FROM + (payload->>'enrolledAt')::TIMESTAMPTZ + INTERVAL '15 minutes' OR + session_document->>'rotatedFromId' IS NOT NULL OR + audit_event->>'action' IS DISTINCT FROM 'invitation.accepted' OR + audit_event->>'organizationId' IS DISTINCT FROM invitation.organization_id OR + audit_event#>>'{target,type}' IS DISTINCT FROM 'invitation' OR + audit_event#>>'{target,id}' IS DISTINCT FROM invitation.id OR + audit_event->>'occurredAt' IS DISTINCT FROM payload->>'enrolledAt' THEN + RAISE EXCEPTION 'invitation enrollment is invalid' USING ERRCODE = 'A0003'; + END IF; + + PERFORM aether_identity.insert_user(user_document); + PERFORM aether_identity.insert_membership(membership_document); + PERFORM aether_identity.insert_session(session_document); + accepted_invitation := invitation.document || jsonb_build_object( + 'state', 'accepted', + 'version', invitation.version + 1, + 'acceptedAt', payload->'enrolledAt', + 'acceptedByUserId', user_document->'id' + ); + UPDATE aether_identity.invitations + SET state = 'accepted', + version = invitation.version + 1, + document = accepted_invitation + WHERE id = invitation.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'enroll_invitation', + jsonb_build_object( + 'invitation', accepted_invitation, + 'user', user_document, + 'membership', membership_document, + 'enrollmentSession', session_document, + 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_revoke_federated_sessions(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + audit_event JSONB; + revoked_ids JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'revoke_federated_sessions'); + audit_event := payload->'auditEvent'; + PERFORM 1 FROM aether_identity.organizations + WHERE id = payload->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'federation organization not found' USING ERRCODE = 'A0012'; + END IF; + IF COALESCE(length(payload->>'providerKey'), 0) NOT BETWEEN 1 AND 512 OR + COALESCE(payload->>'providerKey', '') !~ '[^[:space:]]' OR + COALESCE(length(payload->>'reasonCode'), 0) NOT BETWEEN 1 AND 200 OR + COALESCE(payload->>'reasonCode', '') !~ '[^[:space:]]' OR + audit_event->>'organizationId' IS DISTINCT FROM payload->>'organizationId' OR + audit_event->>'action' IS DISTINCT FROM 'session.revoked' THEN + RAISE EXCEPTION 'federated session revocation is invalid' USING ERRCODE = 'A0003'; + END IF; + IF EXISTS ( + SELECT 1 FROM aether_identity.sessions + WHERE state = 'active' AND + authentication_method IN ('oidc', 'saml') AND + federation_organization_id = payload->>'organizationId' AND + federation_provider_key = payload->>'providerKey' AND + (document->>'createdAt')::TIMESTAMPTZ > (payload->>'revokedAt')::TIMESTAMPTZ + ) THEN + RAISE EXCEPTION 'federated session revocation precedes creation' USING ERRCODE = 'A0003'; + END IF; + WITH revoked AS ( + UPDATE aether_identity.sessions + SET state = 'revoked', + version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', + 'version', version + 1, + 'revokedAt', payload->'revokedAt', + 'revocationReasonCode', payload->'reasonCode' + ) + WHERE state = 'active' AND + authentication_method IN ('oidc', 'saml') AND + federation_organization_id = payload->>'organizationId' AND + federation_provider_key = payload->>'providerKey' + RETURNING id + ) + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) + INTO revoked_ids FROM revoked; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'revoke_federated_sessions', + jsonb_build_object( + 'organizationId', payload->'organizationId', + 'providerKey', payload->'providerKey', + 'revokedSessionIds', revoked_ids, + 'auditEvent', audit_event + ) + ); +END; +$$; + +-- V002 also freezes the complete SCIM command as the idempotency fingerprint. V001 stored only +-- the mutation document, so existing receipts are losslessly backfilled from their canonical +-- commit before the column becomes mandatory. +ALTER TABLE aether_identity.scim_operations + ADD COLUMN command_document JSONB; + +UPDATE aether_identity.scim_operations + SET command_document = jsonb_build_object( + 'mutation', mutation, + 'auditEvent', commit_result->'auditEvent' + ); + +ALTER TABLE aether_identity.scim_operations + ALTER COLUMN command_document SET NOT NULL; + +CREATE TABLE aether_identity.scim_groups ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + provider TEXT NOT NULL, + external_id TEXT, + state TEXT NOT NULL CHECK (state IN ('active', 'deleted')), + version BIGINT NOT NULL CHECK (version >= 1), + document JSONB NOT NULL, + CHECK (document->>'id' = id), + CHECK (document->>'organizationId' = organization_id), + CHECK (document->>'provider' = provider), + CHECK ((document->>'version')::BIGINT = version), + CHECK (document->>'state' = state), + CHECK ((document->>'externalId') IS NOT DISTINCT FROM external_id) +); + +CREATE INDEX scim_groups_provider_organization_idx + ON aether_identity.scim_groups (provider, organization_id, id); + +CREATE TABLE aether_identity.scim_batch_operations ( + operation_id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + provider TEXT NOT NULL, + command_document JSONB NOT NULL, + commit_result JSONB NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL +); + +CREATE OR REPLACE FUNCTION aether_identity.sorted_distinct_text_jsonb(p_values JSONB) +RETURNS JSONB +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT COALESCE(jsonb_agg(to_jsonb(value) ORDER BY value), '[]'::JSONB) + FROM ( + SELECT DISTINCT jsonb_array_elements_text(COALESCE(p_values, '[]'::JSONB)) AS value + ) AS distinct_values; +$$; + +-- Shared by the legacy one-mutation RPC and the all-or-nothing batch RPC. The caller decides +-- whether last-owner protection is evaluated per mutation or once against the batch final state. +CREATE OR REPLACE FUNCTION aether_identity.apply_scim_mutation_command( + p_command JSONB, + p_enforce_last_owner BOOLEAN +) RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + mutation_document JSONB; + audit_event JSONB; + user_document JSONB; + membership_document JSONB; + stored_user aether_identity.users%ROWTYPE; + current_membership aether_identity.memberships%ROWTYPE; + existing_command JSONB; + existing_commit JSONB; + commit_result JSONB; + remaining_owners BIGINT; +BEGIN + IF jsonb_typeof(p_command) IS DISTINCT FROM 'object' OR + jsonb_typeof(p_command->'mutation') IS DISTINCT FROM 'object' OR + jsonb_typeof(p_command->'auditEvent') IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'SCIM mutation command is invalid' USING ERRCODE = 'A0003'; + END IF; + mutation_document := p_command->'mutation'; + audit_event := p_command->'auditEvent'; + + IF COALESCE(length(mutation_document->>'operationId'), 0) = 0 OR + COALESCE(length(mutation_document->>'provider'), 0) NOT BETWEEN 1 AND 512 OR + COALESCE(mutation_document->>'provider', '') !~ '[^[:space:]]' OR + audit_event->>'action' IS DISTINCT FROM 'scim.mutation_applied' THEN + RAISE EXCEPTION 'SCIM mutation command is invalid' USING ERRCODE = 'A0003'; + END IF; + + PERFORM pg_advisory_xact_lock( + hashtext('aether_identity.scim:' || (mutation_document->>'operationId')) + ); + SELECT scim_operations.command_document, scim_operations.commit_result + INTO existing_command, existing_commit + FROM aether_identity.scim_operations + WHERE operation_id = mutation_document->>'operationId' + FOR UPDATE; + IF FOUND THEN + IF existing_command IS DISTINCT FROM p_command THEN + RAISE EXCEPTION 'SCIM operation idempotency conflict' USING ERRCODE = 'A0006'; + END IF; + RETURN existing_commit || jsonb_build_object( + 'alreadyApplied', TRUE, + 'auditEvent', 'null'::JSONB + ); + END IF; + + IF mutation_document->>'type' IN ('upsert_user', 'deactivate_user') THEN + user_document := mutation_document->'user'; + membership_document := 'null'::JSONB; + IF user_document IS NULL OR user_document = 'null'::JSONB OR + (mutation_document->>'type' = 'deactivate_user' AND + user_document->>'state' IS DISTINCT FROM 'deactivated') THEN + RAISE EXCEPTION 'SCIM user mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored_user + FROM aether_identity.users + WHERE id = user_document->>'id' + FOR UPDATE; + IF NOT FOUND THEN + PERFORM aether_identity.insert_user(user_document); + ELSIF (user_document->>'version')::BIGINT = stored_user.version + 1 THEN + PERFORM aether_identity.replace_user(user_document, stored_user.version); + ELSE + RAISE EXCEPTION 'SCIM user version conflict' USING ERRCODE = 'A0002'; + END IF; + ELSIF mutation_document->>'type' IN ('upsert_membership', 'remove_membership') THEN + user_document := 'null'::JSONB; + membership_document := mutation_document->'membership'; + IF membership_document IS NULL OR membership_document = 'null'::JSONB OR + (mutation_document->>'type' = 'remove_membership' AND + membership_document->>'state' IS DISTINCT FROM 'removed') THEN + RAISE EXCEPTION 'SCIM membership mutation is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM 1 FROM aether_identity.organizations + WHERE id = membership_document->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'SCIM organization not found' USING ERRCODE = 'A0012'; + END IF; + PERFORM 1 FROM aether_identity.users + WHERE id = membership_document->>'userId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'SCIM membership user not found' USING ERRCODE = 'A0012'; + END IF; + SELECT * INTO current_membership + FROM aether_identity.memberships + WHERE id = membership_document->>'id' + FOR UPDATE; + IF NOT FOUND THEN + IF (membership_document->>'version')::BIGINT IS DISTINCT FROM 0 THEN + RAISE EXCEPTION 'new SCIM membership version is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.memberships( + id, organization_id, user_id, role, state, version, document + ) VALUES ( + membership_document->>'id', membership_document->>'organizationId', + membership_document->>'userId', membership_document->>'role', + membership_document->>'state', (membership_document->>'version')::BIGINT, + membership_document + ); + ELSE + IF current_membership.organization_id IS DISTINCT FROM membership_document->>'organizationId' OR + current_membership.user_id IS DISTINCT FROM membership_document->>'userId' OR + (membership_document->>'version')::BIGINT IS DISTINCT FROM current_membership.version + 1 THEN + RAISE EXCEPTION 'SCIM membership version conflict' USING ERRCODE = 'A0002'; + END IF; + IF p_enforce_last_owner AND + current_membership.role = 'owner' AND current_membership.state = 'active' AND + NOT (membership_document->>'role' = 'owner' AND + membership_document->>'state' = 'active') THEN + SELECT COUNT(*) INTO remaining_owners + FROM aether_identity.memberships + WHERE organization_id = current_membership.organization_id AND + id <> current_membership.id AND role = 'owner' AND state = 'active'; + IF remaining_owners = 0 THEN + RAISE EXCEPTION 'last owner cannot be removed' USING ERRCODE = 'A0004'; + END IF; + END IF; + UPDATE aether_identity.memberships + SET role = membership_document->>'role', + state = membership_document->>'state', + version = (membership_document->>'version')::BIGINT, + document = membership_document + WHERE id = current_membership.id; + END IF; + ELSE + RAISE EXCEPTION 'unsupported SCIM mutation type' USING ERRCODE = 'A0003'; + END IF; + + PERFORM aether_identity.record_audit(audit_event); + commit_result := jsonb_build_object( + 'user', user_document, + 'membership', membership_document, + 'alreadyApplied', FALSE, + 'auditEvent', audit_event + ); + INSERT INTO aether_identity.scim_operations( + operation_id, provider, mutation, command_document, commit_result, occurred_at + ) VALUES ( + mutation_document->>'operationId', mutation_document->>'provider', mutation_document, + p_command, commit_result, (mutation_document->>'occurredAt')::TIMESTAMPTZ + ); + RETURN commit_result; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_apply_scim_mutation(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + payload JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'apply_scim_mutation'); + RETURN aether_identity.rpc_success( + 'apply_scim_mutation', + aether_identity.apply_scim_mutation_command(payload, TRUE) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_scim_group(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + payload JSONB; + result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_scim_group'); + SELECT document INTO result + FROM aether_identity.scim_groups + WHERE id = payload->>'id' AND + organization_id = payload->>'organizationId' AND + provider = payload->>'provider'; + RETURN aether_identity.rpc_success('find_scim_group', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_apply_scim_batch(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + payload JSONB; + organization aether_identity.organizations%ROWTYPE; + existing_command JSONB; + existing_commit JSONB; + replay_mutation_commits JSONB; + mutations JSONB; + revocations JSONB; + group_document JSONB; + audit_event JSONB; + expected_group_version BIGINT; + current_group aether_identity.scim_groups%ROWTYPE; + child JSONB; + revocation JSONB; + mutation_commits JSONB := '[]'::JSONB; + revoked_session_ids JSONB := '[]'::JSONB; + revoked_family_ids JSONB := '[]'::JSONB; + revoked_access_ids JSONB := '[]'::JSONB; + revoked_refresh_ids JSONB := '[]'::JSONB; + newly_revoked JSONB; + current_owner_count BIGINT; + prospective_owner_count BIGINT; + distinct_count BIGINT; + total_count BIGINT; + commit_result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'apply_scim_batch'); + mutations := payload->'mutations'; + revocations := payload->'revocations'; + group_document := payload->'group'; + audit_event := payload->'auditEvent'; + + IF jsonb_typeof(payload) IS DISTINCT FROM 'object' OR + COALESCE(length(payload->>'operationId'), 0) = 0 OR + COALESCE(length(payload->>'provider'), 0) NOT BETWEEN 1 AND 512 OR + COALESCE(payload->>'provider', '') !~ '[^[:space:]]' OR + jsonb_typeof(mutations) IS DISTINCT FROM 'array' OR + jsonb_typeof(revocations) IS DISTINCT FROM 'array' OR + jsonb_array_length(mutations) > 10000 OR + jsonb_array_length(revocations) > 10000 OR + jsonb_typeof(audit_event) IS DISTINCT FROM 'object' OR + audit_event->>'organizationId' IS DISTINCT FROM payload->>'organizationId' THEN + RAISE EXCEPTION 'SCIM batch command is invalid' USING ERRCODE = 'A0003'; + END IF; + + PERFORM pg_advisory_xact_lock( + hashtext('aether_identity.scim_batch:' || (payload->>'operationId')) + ); + SELECT command_document, scim_batch_operations.commit_result + INTO existing_command, existing_commit + FROM aether_identity.scim_batch_operations + WHERE operation_id = payload->>'operationId' + FOR UPDATE; + IF FOUND THEN + IF existing_command IS DISTINCT FROM payload THEN + RAISE EXCEPTION 'SCIM batch idempotency conflict' USING ERRCODE = 'A0006'; + END IF; + SELECT COALESCE( + jsonb_agg( + value || jsonb_build_object( + 'alreadyApplied', TRUE, + 'auditEvent', 'null'::JSONB + ) ORDER BY ordinal + ), + '[]'::JSONB + ) + INTO replay_mutation_commits + FROM jsonb_array_elements(existing_commit->'mutationCommits') + WITH ORDINALITY AS replay(value, ordinal); + RETURN aether_identity.rpc_success( + 'apply_scim_batch', + existing_commit || jsonb_build_object( + 'mutationCommits', replay_mutation_commits, + 'alreadyApplied', TRUE, + 'auditEvent', 'null'::JSONB + ) + ); + END IF; + + SELECT * INTO organization + FROM aether_identity.organizations + WHERE id = payload->>'organizationId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'SCIM organization not found' USING ERRCODE = 'A0012'; + END IF; + IF organization.state <> 'active' THEN + RAISE EXCEPTION 'SCIM organization is not active' USING ERRCODE = 'A0003'; + END IF; + + -- Validate the cross-command invariants before taking any child receipt locks or applying work. + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(mutations) AS mutation(value) + WHERE jsonb_typeof(value) IS DISTINCT FROM 'object' OR + jsonb_typeof(value->'mutation') IS DISTINCT FROM 'object' OR + jsonb_typeof(value->'auditEvent') IS DISTINCT FROM 'object' OR + value#>>'{mutation,provider}' IS DISTINCT FROM payload->>'provider' OR + value#>>'{auditEvent,organizationId}' IS DISTINCT FROM payload->>'organizationId' OR + value#>>'{auditEvent,action}' IS DISTINCT FROM 'scim.mutation_applied' OR + ( + value#>>'{mutation,type}' IN ('upsert_membership', 'remove_membership') AND + value#>>'{mutation,membership,organizationId}' IS DISTINCT FROM payload->>'organizationId' + ) + ) THEN + RAISE EXCEPTION 'SCIM child mutation is outside the batch tenant' USING ERRCODE = 'A0003'; + END IF; + + SELECT COUNT(*), COUNT(DISTINCT value#>>'{mutation,operationId}') + INTO total_count, distinct_count + FROM jsonb_array_elements(mutations) AS mutation(value); + IF total_count <> distinct_count THEN + RAISE EXCEPTION 'SCIM child operation ids are not unique' USING ERRCODE = 'A0003'; + END IF; + SELECT COUNT(*), COUNT(DISTINCT audit_id) + INTO total_count, distinct_count + FROM ( + SELECT value#>>'{auditEvent,id}' AS audit_id + FROM jsonb_array_elements(mutations) AS mutation(value) + UNION ALL + SELECT audit_event->>'id' + ) AS audit_ids; + IF total_count <> distinct_count THEN + RAISE EXCEPTION 'SCIM audit ids are not unique' USING ERRCODE = 'A0003'; + END IF; + SELECT COUNT(*), COUNT(DISTINCT value#>>'{mutation,user,id}') + INTO total_count, distinct_count + FROM jsonb_array_elements(mutations) AS mutation(value) + WHERE value#>>'{mutation,user,id}' IS NOT NULL; + IF total_count <> distinct_count THEN + RAISE EXCEPTION 'SCIM users are mutated more than once' USING ERRCODE = 'A0003'; + END IF; + SELECT COUNT(*), COUNT(DISTINCT value#>>'{mutation,membership,id}') + INTO total_count, distinct_count + FROM jsonb_array_elements(mutations) AS mutation(value) + WHERE value#>>'{mutation,membership,id}' IS NOT NULL; + IF total_count <> distinct_count THEN + RAISE EXCEPTION 'SCIM memberships are mutated more than once' USING ERRCODE = 'A0003'; + END IF; + SELECT COUNT(*), COUNT(DISTINCT value->>'userId') + INTO total_count, distinct_count + FROM jsonb_array_elements(revocations) AS tenant_revocation(value); + IF total_count <> distinct_count OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(revocations) AS tenant_revocation(value) + WHERE COALESCE(length(value->>'reasonCode'), 0) NOT BETWEEN 1 AND 200 OR + COALESCE(value->>'reasonCode', '') !~ '[^[:space:]]' OR + NOT ( + COALESCE((value->>'revokeSessions')::BOOLEAN, FALSE) OR + COALESCE((value->>'revokeDeviceTokenFamilies')::BOOLEAN, FALSE) + ) + ) THEN + RAISE EXCEPTION 'SCIM tenant revocations are invalid' USING ERRCODE = 'A0003'; + END IF; + + IF group_document IS NULL OR group_document = 'null'::JSONB THEN + IF payload->'expectedGroupVersion' IS NOT NULL AND + payload->'expectedGroupVersion' <> 'null'::JSONB THEN + RAISE EXCEPTION 'SCIM group version without a group' USING ERRCODE = 'A0003'; + END IF; + group_document := 'null'::JSONB; + IF audit_event->>'action' IS DISTINCT FROM 'scim.mutation_applied' THEN + RAISE EXCEPTION 'SCIM user batch audit is invalid' USING ERRCODE = 'A0003'; + END IF; + ELSE + expected_group_version := (payload->>'expectedGroupVersion')::BIGINT; + IF expected_group_version IS NULL OR expected_group_version < 0 OR + (group_document->>'version')::BIGINT IS DISTINCT FROM expected_group_version + 1 OR + group_document->>'id' !~ '^[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}$' OR + group_document->>'organizationId' IS DISTINCT FROM payload->>'organizationId' OR + group_document->>'provider' IS DISTINCT FROM payload->>'provider' OR + audit_event->>'action' IS DISTINCT FROM 'scim.group_changed' OR + audit_event#>>'{target,type}' IS DISTINCT FROM 'scim_group' OR + audit_event#>>'{target,id}' IS DISTINCT FROM group_document->>'id' OR + COALESCE(length(group_document->>'displayName'), 0) NOT BETWEEN 1 AND 200 OR + COALESCE(group_document->>'displayName', '') !~ '[^[:space:]]' OR + ( + group_document->>'externalId' IS NOT NULL AND + ( + length(group_document->>'externalId') NOT BETWEEN 1 AND 1024 OR + COALESCE(group_document->>'externalId', '') !~ '[^[:space:]]' + ) + ) OR + group_document->>'state' NOT IN ('active', 'deleted') OR + jsonb_typeof(group_document->'memberUserIds') IS DISTINCT FROM 'array' OR + jsonb_array_length(group_document->'memberUserIds') > 5000 OR + ( + SELECT COUNT(*) FROM jsonb_array_elements_text(group_document->'memberUserIds') + ) <> ( + SELECT COUNT(DISTINCT value) + FROM jsonb_array_elements_text(group_document->'memberUserIds') AS member(value) + ) OR + (group_document->>'updatedAt')::TIMESTAMPTZ < (group_document->>'createdAt')::TIMESTAMPTZ OR + (group_document->>'state' = 'active' AND group_document->>'deletedAt' IS NOT NULL) OR + (group_document->>'state' = 'deleted' AND group_document->>'deletedAt' IS NULL) OR + ( + group_document->>'deletedAt' IS NOT NULL AND + (group_document->>'deletedAt')::TIMESTAMPTZ < + (group_document->>'createdAt')::TIMESTAMPTZ + ) THEN + RAISE EXCEPTION 'SCIM group command is invalid' USING ERRCODE = 'A0003'; + END IF; + END IF; + + -- Lock child receipts in a stable order to avoid two overlapping batches deadlocking. + PERFORM pg_advisory_xact_lock(hashtext('aether_identity.scim:' || operation_id)) + FROM ( + SELECT value#>>'{mutation,operationId}' AS operation_id + FROM jsonb_array_elements(mutations) AS mutation(value) + ORDER BY operation_id + ) AS ordered_operations; + + -- Batch last-owner protection evaluates the complete membership fan-out, not mutation order. + PERFORM 1 + FROM aether_identity.memberships + WHERE organization_id = organization.id + ORDER BY id + FOR UPDATE; + SELECT COUNT(*) INTO current_owner_count + FROM aether_identity.memberships + WHERE organization_id = organization.id AND role = 'owner' AND state = 'active'; + WITH replacements AS ( + SELECT value->'mutation'->'membership' AS document + FROM jsonb_array_elements(mutations) AS mutation(value) + WHERE value#>>'{mutation,type}' IN ('upsert_membership', 'remove_membership') + ), prospective AS ( + SELECT membership.document + FROM aether_identity.memberships AS membership + WHERE membership.organization_id = organization.id AND + NOT EXISTS ( + SELECT 1 FROM replacements + WHERE replacements.document->>'id' = membership.id + ) + UNION ALL + SELECT document FROM replacements + ) + SELECT COUNT(*) INTO prospective_owner_count + FROM prospective + WHERE document->>'organizationId' = organization.id AND + document->>'role' = 'owner' AND document->>'state' = 'active'; + IF current_owner_count > 0 AND prospective_owner_count = 0 THEN + RAISE EXCEPTION 'last owner cannot be removed' USING ERRCODE = 'A0004'; + END IF; + + FOR child IN SELECT value FROM jsonb_array_elements(mutations) AS mutation(value) + LOOP + mutation_commits := mutation_commits || jsonb_build_array( + aether_identity.apply_scim_mutation_command(child, FALSE) + ); + END LOOP; + + IF group_document <> 'null'::JSONB THEN + SELECT * INTO current_group + FROM aether_identity.scim_groups + WHERE id = group_document->>'id' + FOR UPDATE; + IF expected_group_version = 0 THEN + IF FOUND THEN + RAISE EXCEPTION 'SCIM group already exists' USING ERRCODE = 'A0013'; + END IF; + ELSE + IF NOT FOUND OR current_group.organization_id IS DISTINCT FROM organization.id OR + current_group.provider IS DISTINCT FROM payload->>'provider' THEN + RAISE EXCEPTION 'SCIM group not found' USING ERRCODE = 'A0012'; + END IF; + IF current_group.version IS DISTINCT FROM expected_group_version THEN + RAISE EXCEPTION 'SCIM group version conflict' USING ERRCODE = 'A0002'; + END IF; + IF current_group.document->>'createdAt' IS DISTINCT FROM group_document->>'createdAt' THEN + RAISE EXCEPTION 'SCIM group creation timestamp changed' USING ERRCODE = 'A0003'; + END IF; + END IF; + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(group_document->'memberUserIds') AS member(user_id) + WHERE NOT EXISTS ( + SELECT 1 FROM aether_identity.users WHERE id = member.user_id + ) + ) THEN + RAISE EXCEPTION 'SCIM group member not found' USING ERRCODE = 'A0012'; + END IF; + IF expected_group_version = 0 THEN + INSERT INTO aether_identity.scim_groups( + id, organization_id, provider, external_id, state, version, document + ) VALUES ( + group_document->>'id', organization.id, payload->>'provider', + NULLIF(group_document->>'externalId', ''), group_document->>'state', + (group_document->>'version')::BIGINT, group_document + ); + ELSE + UPDATE aether_identity.scim_groups + SET external_id = NULLIF(group_document->>'externalId', ''), + state = group_document->>'state', + version = (group_document->>'version')::BIGINT, + document = group_document + WHERE id = current_group.id; + END IF; + END IF; + + FOR revocation IN SELECT value FROM jsonb_array_elements(revocations) AS tenant_revocation(value) + LOOP + PERFORM 1 FROM aether_identity.users WHERE id = revocation->>'userId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'SCIM revocation user not found' USING ERRCODE = 'A0012'; + END IF; + + IF COALESCE((revocation->>'revokeSessions')::BOOLEAN, FALSE) THEN + IF EXISTS ( + SELECT 1 FROM aether_identity.sessions + WHERE user_id = revocation->>'userId' AND state = 'active' AND + federation_organization_id = organization.id AND + (document->>'createdAt')::TIMESTAMPTZ > (audit_event->>'occurredAt')::TIMESTAMPTZ + ) THEN + RAISE EXCEPTION 'SCIM revocation predates a session' USING ERRCODE = 'A0003'; + END IF; + WITH revoked AS ( + UPDATE aether_identity.sessions + SET state = 'revoked', + version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', + 'version', version + 1, + 'revokedAt', audit_event->'occurredAt', + 'revocationReasonCode', revocation->'reasonCode' + ) + WHERE user_id = revocation->>'userId' AND state = 'active' AND + federation_organization_id = organization.id + RETURNING id + ) + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) + INTO newly_revoked FROM revoked; + revoked_session_ids := revoked_session_ids || newly_revoked; + END IF; + + IF COALESCE((revocation->>'revokeDeviceTokenFamilies')::BOOLEAN, FALSE) THEN + PERFORM 1 + FROM aether_identity.device_token_families + WHERE user_id = revocation->>'userId' AND organization_id = organization.id AND + state = 'active' + ORDER BY id + FOR UPDATE; + IF EXISTS ( + SELECT 1 FROM aether_identity.device_token_families + WHERE user_id = revocation->>'userId' AND organization_id = organization.id AND + state = 'active' AND + (document->>'createdAt')::TIMESTAMPTZ > (audit_event->>'occurredAt')::TIMESTAMPTZ + ) OR EXISTS ( + SELECT 1 + FROM aether_identity.device_access_tokens AS token + JOIN aether_identity.device_token_families AS family ON family.id = token.family_id + WHERE family.user_id = revocation->>'userId' AND + family.organization_id = organization.id AND family.state = 'active' AND + token.state = 'active' AND + (token.document->>'createdAt')::TIMESTAMPTZ > (audit_event->>'occurredAt')::TIMESTAMPTZ + ) OR EXISTS ( + SELECT 1 + FROM aether_identity.device_refresh_tokens AS token + JOIN aether_identity.device_token_families AS family ON family.id = token.family_id + WHERE family.user_id = revocation->>'userId' AND + family.organization_id = organization.id AND family.state = 'active' AND + token.state = 'active' AND + (token.document->>'createdAt')::TIMESTAMPTZ > (audit_event->>'occurredAt')::TIMESTAMPTZ + ) THEN + RAISE EXCEPTION 'SCIM revocation predates a device token' USING ERRCODE = 'A0003'; + END IF; + WITH revoked AS ( + UPDATE aether_identity.device_access_tokens AS token + SET state = 'revoked', + version = token.version + 1, + document = token.document || jsonb_build_object( + 'state', 'revoked', + 'version', token.version + 1, + 'revokedAt', audit_event->'occurredAt' + ) + FROM aether_identity.device_token_families AS family + WHERE token.family_id = family.id AND family.user_id = revocation->>'userId' AND + family.organization_id = organization.id AND family.state = 'active' AND + token.state = 'active' + RETURNING token.id + ) + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) + INTO newly_revoked FROM revoked; + revoked_access_ids := revoked_access_ids || newly_revoked; + + WITH revoked AS ( + UPDATE aether_identity.device_refresh_tokens AS token + SET state = 'revoked', + version = token.version + 1, + document = token.document || jsonb_build_object( + 'state', 'revoked', + 'version', token.version + 1, + 'revokedAt', audit_event->'occurredAt' + ) + FROM aether_identity.device_token_families AS family + WHERE token.family_id = family.id AND family.user_id = revocation->>'userId' AND + family.organization_id = organization.id AND family.state = 'active' AND + token.state = 'active' + RETURNING token.id + ) + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) + INTO newly_revoked FROM revoked; + revoked_refresh_ids := revoked_refresh_ids || newly_revoked; + + WITH revoked AS ( + UPDATE aether_identity.device_token_families + SET state = 'revoked', + version = version + 1, + document = document || jsonb_build_object( + 'state', 'revoked', + 'version', version + 1, + 'revokedAt', audit_event->'occurredAt', + 'revocationReasonCode', revocation->'reasonCode' + ) + WHERE user_id = revocation->>'userId' AND organization_id = organization.id AND + state = 'active' + RETURNING id + ) + SELECT COALESCE(jsonb_agg(to_jsonb(id) ORDER BY id), '[]'::JSONB) + INTO newly_revoked FROM revoked; + revoked_family_ids := revoked_family_ids || newly_revoked; + END IF; + END LOOP; + + revoked_session_ids := aether_identity.sorted_distinct_text_jsonb(revoked_session_ids); + revoked_family_ids := aether_identity.sorted_distinct_text_jsonb(revoked_family_ids); + revoked_access_ids := aether_identity.sorted_distinct_text_jsonb(revoked_access_ids); + revoked_refresh_ids := aether_identity.sorted_distinct_text_jsonb(revoked_refresh_ids); + + PERFORM aether_identity.record_audit(audit_event); + commit_result := jsonb_build_object( + 'mutationCommits', mutation_commits, + 'group', group_document, + 'revokedSessionIds', revoked_session_ids, + 'revokedDeviceTokenFamilyIds', revoked_family_ids, + 'revokedDeviceAccessTokenIds', revoked_access_ids, + 'revokedDeviceRefreshTokenIds', revoked_refresh_ids, + 'alreadyApplied', FALSE, + 'auditEvent', audit_event + ); + INSERT INTO aether_identity.scim_batch_operations( + operation_id, organization_id, provider, command_document, commit_result, occurred_at + ) VALUES ( + payload->>'operationId', organization.id, payload->>'provider', payload, commit_result, + (audit_event->>'occurredAt')::TIMESTAMPTZ + ); + RETURN aether_identity.rpc_success('apply_scim_batch', commit_result); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V003__organization_audit_reads.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V003__organization_audit_reads.sql new file mode 100644 index 0000000..2319cb2 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V003__organization_audit_reads.sql @@ -0,0 +1,99 @@ +-- Additive tenant audit read path. V001/V002 remain immutable after deployment. + +CREATE INDEX IF NOT EXISTS audit_events_organization_time_id_idx + ON aether_identity.audit_events (organization_id, occurred_at DESC, id DESC); + +CREATE OR REPLACE FUNCTION aether_identity.v1_list_audit_events_for_organization(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + payload JSONB; + requested_organization_id TEXT; + page_limit INTEGER; + cursor_document JSONB; + cursor_occurred_at TIMESTAMPTZ := NULL; + cursor_id TEXT := NULL; + page_events JSONB; + has_more BOOLEAN; + next_cursor JSONB := NULL; + last_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'list_audit_events_for_organization'); + requested_organization_id := payload->>'organizationId'; + IF requested_organization_id IS NULL OR requested_organization_id = '' THEN + RAISE EXCEPTION 'organization audit scope is invalid' USING ERRCODE = 'A0003'; + END IF; + + BEGIN + page_limit := (payload->>'limit')::INTEGER; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'organization audit limit is invalid' USING ERRCODE = 'A0003'; + END; + IF page_limit < 1 OR page_limit > 100 THEN + RAISE EXCEPTION 'organization audit limit is invalid' USING ERRCODE = 'A0003'; + END IF; + + cursor_document := payload->'cursor'; + IF cursor_document IS NOT NULL AND cursor_document <> 'null'::JSONB THEN + IF jsonb_typeof(cursor_document) <> 'object' OR + cursor_document->>'organizationId' IS NULL OR + cursor_document->>'occurredAt' IS NULL OR cursor_document->>'id' IS NULL OR + cursor_document->>'id' = '' THEN + RAISE EXCEPTION 'organization audit cursor is invalid' USING ERRCODE = 'A0003'; + END IF; + IF cursor_document->>'organizationId' <> requested_organization_id THEN + RAISE EXCEPTION 'organization audit cursor tenant is invalid' USING ERRCODE = 'A0003'; + END IF; + BEGIN + cursor_occurred_at := (cursor_document->>'occurredAt')::TIMESTAMPTZ; + cursor_id := cursor_document->>'id'; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'organization audit cursor is invalid' USING ERRCODE = 'A0003'; + END; + END IF; + + WITH selected AS ( + SELECT event.document, event.occurred_at, event.id + FROM aether_identity.audit_events AS event + WHERE event.organization_id = requested_organization_id + AND ( + cursor_occurred_at IS NULL OR + (event.occurred_at, event.id) < (cursor_occurred_at, cursor_id) + ) + ORDER BY event.occurred_at DESC, event.id DESC + LIMIT page_limit + 1 + ), numbered AS ( + SELECT document, occurred_at, id, + row_number() OVER (ORDER BY occurred_at DESC, id DESC) AS position + FROM selected + ) + SELECT COALESCE( + jsonb_agg(document ORDER BY occurred_at DESC, id DESC) + FILTER (WHERE position <= page_limit), + '[]'::JSONB + ), + count(*) > page_limit + INTO page_events, has_more + FROM numbered; + + IF has_more THEN + last_event := page_events->(jsonb_array_length(page_events) - 1); + next_cursor := jsonb_build_object( + 'organizationId', requested_organization_id, + 'occurredAt', last_event->'occurredAt', + 'id', last_event->'id' + ); + END IF; + + RETURN aether_identity.rpc_success( + 'list_audit_events_for_organization', + jsonb_build_object( + 'organizationId', requested_organization_id, + 'events', page_events, + 'nextCursor', next_cursor + ) + ); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V004__audit_retention.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V004__audit_retention.sql new file mode 100644 index 0000000..db803c2 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V004__audit_retention.sql @@ -0,0 +1,58 @@ +-- Bounded, storage-neutral audit retention. Earlier migrations remain immutable. + +CREATE INDEX IF NOT EXISTS audit_events_retention_idx + ON aether_identity.audit_events (occurred_at ASC, id ASC); + +CREATE OR REPLACE FUNCTION aether_identity.v1_purge_audit_events(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + cutoff TIMESTAMPTZ; + maximum_events INTEGER; + deleted_count INTEGER; + has_more BOOLEAN; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'purge_audit_events'); + IF payload->>'occurredBefore' IS NULL OR payload->>'maximumEvents' IS NULL THEN + RAISE EXCEPTION 'audit retention request is invalid' USING ERRCODE = 'A0003'; + END IF; + BEGIN + cutoff := (payload->>'occurredBefore')::TIMESTAMPTZ; + maximum_events := (payload->>'maximumEvents')::INTEGER; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'audit retention request is invalid' USING ERRCODE = 'A0003'; + END; + IF maximum_events < 1 OR maximum_events > 500 THEN + RAISE EXCEPTION 'audit retention batch is invalid' USING ERRCODE = 'A0003'; + END IF; + + WITH selected AS ( + SELECT event.id + FROM aether_identity.audit_events AS event + WHERE event.occurred_at < cutoff + ORDER BY event.occurred_at ASC, event.id ASC + FOR UPDATE SKIP LOCKED + LIMIT maximum_events + ), deleted AS ( + DELETE FROM aether_identity.audit_events AS event + USING selected + WHERE event.id = selected.id + RETURNING event.id + ) + SELECT count(*) INTO deleted_count FROM deleted; + + SELECT EXISTS ( + SELECT 1 + FROM aether_identity.audit_events AS event + WHERE event.occurred_at < cutoff + ) INTO has_more; + + RETURN aether_identity.rpc_success( + 'purge_audit_events', + jsonb_build_object('deletedCount', deleted_count, 'hasMore', has_more) + ); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V005__device_grant_cas_serialization.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V005__device_grant_cas_serialization.sql new file mode 100644 index 0000000..74792ec --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V005__device_grant_cas_serialization.sql @@ -0,0 +1,118 @@ +-- Normalize same-ID device-grant insert races to the IdentityStore CAS contract without changing +-- device-code or user-code uniqueness semantics. The transaction-scoped advisory lock is keyed +-- only by the grant ID: different grant IDs still race through the unique digest reservations and +-- report UNIQUE_CONSTRAINT when their device/user codes collide. + +CREATE OR REPLACE FUNCTION aether_identity.v1_compare_and_set_device_grant(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE payload JSONB; replacement JSONB; stored aether_identity.device_grants%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'compare_and_set_device_grant'); + replacement := payload->'replacement'; + IF length(COALESCE(replacement->>'clientId', '')) NOT BETWEEN 1 AND 200 OR + replacement->>'clientId' ~ '[^!-~]' OR + length(COALESCE(replacement->>'clientName', '')) NOT BETWEEN 1 AND 200 OR + jsonb_typeof(replacement->'requestedCapabilities') IS DISTINCT FROM 'array' OR + jsonb_array_length(replacement->'requestedCapabilities') = 0 OR + jsonb_typeof(replacement->'approvedCapabilities') IS DISTINCT FROM 'array' OR + NOT ((replacement->'requestedCapabilities') @> (replacement->'approvedCapabilities')) OR + COALESCE((replacement->>'pollingIntervalSeconds')::INTEGER, 0) NOT BETWEEN 5 AND 300 OR + COALESCE((replacement->>'pollCount')::INTEGER, -1) < 0 OR + (replacement->>'expiresAt')::TIMESTAMPTZ <= (replacement->>'createdAt')::TIMESTAMPTZ OR + (replacement->>'state' = 'pending' AND jsonb_array_length(replacement->'approvedCapabilities') <> 0) OR + (replacement->>'state' IN ('authorized', 'consumed') AND ( + jsonb_array_length(replacement->'approvedCapabilities') = 0 OR + replacement->>'userId' IS NULL OR replacement->>'organizationId' IS NULL OR + replacement->>'authorizedByUserId' IS NULL OR replacement->>'authorizedAt' IS NULL + )) OR + (replacement->>'state' = 'denied' AND replacement->>'deniedAt' IS NULL) OR + (replacement->>'state' = 'consumed' AND replacement->>'consumedAt' IS NULL) OR + (replacement->>'state' = 'expired' AND replacement->>'expiredAt' IS NULL) OR + (replacement->>'state' = 'cancelled' AND replacement->>'cancelledAt' IS NULL) THEN + RAISE EXCEPTION 'device grant model is invalid' USING ERRCODE = 'A0003'; + END IF; + + PERFORM pg_advisory_xact_lock( + hashtextextended('aether_identity.device_grant:' || (replacement->>'id'), 0) + ); + SELECT * INTO stored FROM aether_identity.device_grants WHERE id = replacement->>'id' FOR UPDATE; + IF payload->'expectedVersion' IS NULL OR payload->'expectedVersion' = 'null'::JSONB THEN + IF FOUND THEN + RAISE EXCEPTION 'device grant version conflict' USING ERRCODE = 'A0002'; + END IF; + IF replacement->>'state' <> 'pending' OR + (replacement->>'version')::BIGINT <> 0 OR + replacement#>'{deviceCodeDigest}' = replacement#>'{userCodeDigest}' OR + EXISTS ( + SELECT 1 FROM aether_identity.device_grants + WHERE document#>'{deviceCodeDigest}' IN ( + replacement#>'{deviceCodeDigest}', replacement#>'{userCodeDigest}' + ) OR + document#>'{userCodeDigest}' IN ( + replacement#>'{deviceCodeDigest}', replacement#>'{userCodeDigest}' + ) + ) THEN + RAISE EXCEPTION 'new device grant is invalid' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.device_grants( + id, device_digest_algorithm, device_digest_encoded, device_digest_key_version, + user_digest_algorithm, user_digest_encoded, user_digest_key_version, + state, version, expires_at, document + ) VALUES ( + replacement->>'id', replacement#>>'{deviceCodeDigest,algorithm}', + replacement#>>'{deviceCodeDigest,encoded}', replacement#>>'{deviceCodeDigest,keyVersion}', + replacement#>>'{userCodeDigest,algorithm}', replacement#>>'{userCodeDigest,encoded}', + replacement#>>'{userCodeDigest,keyVersion}', replacement->>'state', + (replacement->>'version')::BIGINT, (replacement->>'expiresAt')::TIMESTAMPTZ, replacement + ); + INSERT INTO aether_identity.device_grant_digest_reservations( + digest_algorithm, digest_encoded, digest_key_version, grant_id, digest_kind + ) VALUES + ( + replacement#>>'{deviceCodeDigest,algorithm}', + replacement#>>'{deviceCodeDigest,encoded}', + COALESCE(replacement#>>'{deviceCodeDigest,keyVersion}', ''), + replacement->>'id', + 'device' + ), + ( + replacement#>>'{userCodeDigest,algorithm}', + replacement#>>'{userCodeDigest,encoded}', + COALESCE(replacement#>>'{userCodeDigest,keyVersion}', ''), + replacement->>'id', + 'user' + ); + ELSE + IF NOT FOUND THEN RAISE EXCEPTION 'device grant not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'device grant version conflict' USING ERRCODE = 'A0002'; + END IF; + IF (replacement->>'version')::BIGINT <> stored.version + 1 OR + stored.document#>'{deviceCodeDigest}' <> replacement#>'{deviceCodeDigest}' OR + stored.document#>'{userCodeDigest}' <> replacement#>'{userCodeDigest}' OR + stored.document->>'clientId' <> replacement->>'clientId' OR + stored.document->>'clientName' <> replacement->>'clientName' OR + NOT ((stored.document->'requestedCapabilities') @> (replacement->'requestedCapabilities') AND + (stored.document->'requestedCapabilities') <@ (replacement->'requestedCapabilities')) OR + (stored.document->>'createdAt')::TIMESTAMPTZ <> + (replacement->>'createdAt')::TIMESTAMPTZ OR + stored.expires_at <> (replacement->>'expiresAt')::TIMESTAMPTZ OR + NOT ( + (stored.state = 'pending' AND replacement->>'state' IN ( + 'pending', 'authorized', 'denied', 'expired', 'cancelled' + )) OR + (stored.state = 'authorized' AND replacement->>'state' IN ( + 'authorized', 'consumed', 'expired' + )) + ) THEN + RAISE EXCEPTION 'device grant transition invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.device_grants + SET state = replacement->>'state', version = (replacement->>'version')::BIGINT, + expires_at = (replacement->>'expiresAt')::TIMESTAMPTZ, document = replacement + WHERE id = stored.id; + END IF; + PERFORM aether_identity.record_audit(payload->'auditEvent'); + RETURN aether_identity.rpc_success('compare_and_set_device_grant', replacement); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V006__identity_session_touch.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V006__identity_session_touch.sql new file mode 100644 index 0000000..378fae8 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V006__identity_session_touch.sql @@ -0,0 +1,70 @@ +-- Add an atomic, audit-free sliding idle-expiration touch. Earlier migrations remain immutable. + +CREATE OR REPLACE FUNCTION aether_identity.v1_touch_identity_session(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + expected_version BIGINT; + requested_last_used_at TIMESTAMPTZ; + requested_idle_expires_at TIMESTAMPTZ; + stored aether_identity.sessions%ROWTYPE; + replacement JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'touch_identity_session'); + IF payload->>'sessionId' IS NULL OR + payload->>'expectedVersion' IS NULL OR + payload->>'lastUsedAt' IS NULL OR + payload->>'idleExpiresAt' IS NULL THEN + RAISE EXCEPTION 'identity session touch request is invalid' USING ERRCODE = 'A0003'; + END IF; + BEGIN + expected_version := (payload->>'expectedVersion')::BIGINT; + requested_last_used_at := (payload->>'lastUsedAt')::TIMESTAMPTZ; + requested_idle_expires_at := (payload->>'idleExpiresAt')::TIMESTAMPTZ; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'identity session touch request is invalid' USING ERRCODE = 'A0003'; + END; + IF expected_version < 0 THEN + RAISE EXCEPTION 'identity session touch version is invalid' USING ERRCODE = 'A0003'; + END IF; + + SELECT * INTO stored + FROM aether_identity.sessions + WHERE id = payload->>'sessionId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'session not found' USING ERRCODE = 'A0012'; + END IF; + IF stored.version <> expected_version THEN + RAISE EXCEPTION 'session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' THEN + RAISE EXCEPTION 'session not active' USING ERRCODE = 'A0009'; + END IF; + IF requested_last_used_at >= stored.idle_expires_at OR + requested_last_used_at >= stored.absolute_expires_at THEN + RAISE EXCEPTION 'session expired' USING ERRCODE = 'A0010'; + END IF; + IF requested_last_used_at < (stored.document->>'lastUsedAt')::TIMESTAMPTZ OR + requested_idle_expires_at < requested_last_used_at OR + requested_idle_expires_at > stored.absolute_expires_at THEN + RAISE EXCEPTION 'identity session touch is invalid' USING ERRCODE = 'A0003'; + END IF; + + replacement := stored.document || jsonb_build_object( + 'lastUsedAt', payload->'lastUsedAt', + 'idleExpiresAt', payload->'idleExpiresAt', + 'version', stored.version + 1 + ); + UPDATE aether_identity.sessions + SET version = stored.version + 1, + idle_expires_at = requested_idle_expires_at, + document = replacement + WHERE id = stored.id; + + RETURN aether_identity.rpc_success('touch_identity_session', replacement); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V007__administrative_recovery_activation.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V007__administrative_recovery_activation.sql new file mode 100644 index 0000000..57fa561 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V007__administrative_recovery_activation.sql @@ -0,0 +1,109 @@ +-- A recovery ticket is created inactive. Successful delivery and activation become durable in one +-- transaction, so a notification sink cannot race redemption before its delivery result is audited. + +CREATE OR REPLACE FUNCTION aether_identity.v1_activate_administrative_recovery_ticket(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + stored_challenge aether_identity.challenges%ROWTYPE; + audit_event JSONB; + activated_at TIMESTAMPTZ; + activated JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'activate_administrative_recovery_ticket'); + audit_event := payload->'auditEvent'; + activated_at := (payload->>'activatedAt')::TIMESTAMPTZ; + + SELECT * INTO stored_challenge FROM aether_identity.challenges + WHERE id = payload->>'challengeId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'recovery ticket not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_challenge.version IS DISTINCT FROM (payload->>'expectedChallengeVersion')::BIGINT THEN + RAISE EXCEPTION 'recovery ticket version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_challenge.state <> 'pending' THEN + RAISE EXCEPTION 'recovery ticket is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored_challenge.expires_at <= activated_at THEN + RAISE EXCEPTION 'recovery ticket expired' USING ERRCODE = 'A0008'; + END IF; + IF activated_at IS NULL OR stored_challenge.purpose <> 'account_recovery' OR + stored_challenge.user_id IS NULL OR + (stored_challenge.document->'activatedAt' IS NOT NULL AND + stored_challenge.document->'activatedAt' <> 'null'::JSONB) OR + audit_event->>'action' IS DISTINCT FROM 'recovery.admin_ticket_delivered' OR + audit_event->>'outcome' IS DISTINCT FROM 'succeeded' OR + audit_event#>>'{target,type}' IS DISTINCT FROM 'user' OR + audit_event#>>'{target,id}' IS DISTINCT FROM stored_challenge.user_id OR + (audit_event->>'occurredAt')::TIMESTAMPTZ IS DISTINCT FROM activated_at THEN + RAISE EXCEPTION 'administrative recovery activation is invalid' USING ERRCODE = 'A0003'; + END IF; + + activated := stored_challenge.document || jsonb_build_object( + 'activatedAt', to_jsonb(activated_at), + 'version', stored_challenge.version + 1 + ); + UPDATE aether_identity.challenges + SET version = stored_challenge.version + 1, document = activated + WHERE id = stored_challenge.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'activate_administrative_recovery_ticket', + jsonb_build_object('challenge', activated, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_redeem_administrative_recovery_ticket(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + stored_challenge aether_identity.challenges%ROWTYPE; + session_document JSONB; + audit_event JSONB; + consumed JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'redeem_administrative_recovery_ticket'); + session_document := payload->'recoverySession'; + audit_event := payload->'auditEvent'; + SELECT * INTO stored_challenge FROM aether_identity.challenges + WHERE id = payload->>'challengeId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'recovery ticket not found' USING ERRCODE = 'A0012'; END IF; + IF stored_challenge.version IS DISTINCT FROM (payload->>'expectedChallengeVersion')::BIGINT THEN + RAISE EXCEPTION 'recovery ticket version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_challenge.state <> 'pending' THEN + RAISE EXCEPTION 'recovery ticket is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored_challenge.expires_at <= (payload->>'redeemedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'recovery ticket expired' USING ERRCODE = 'A0008'; + END IF; + IF stored_challenge.purpose <> 'account_recovery' OR stored_challenge.user_id IS NULL OR + stored_challenge.document->'activatedAt' IS NULL OR + stored_challenge.document->'activatedAt' = 'null'::JSONB OR + (stored_challenge.document->>'activatedAt')::TIMESTAMPTZ > (payload->>'redeemedAt')::TIMESTAMPTZ OR + session_document->>'userId' IS DISTINCT FROM stored_challenge.user_id OR + session_document->>'assurance' IS DISTINCT FROM 'recovery' OR + session_document->>'familyId' IS DISTINCT FROM session_document->>'id' OR + session_document->'rotatedFromId' IS DISTINCT FROM 'null'::JSONB OR + (session_document->>'rotationCounter')::BIGINT IS DISTINCT FROM 0 OR + (session_document->>'createdAt')::TIMESTAMPTZ IS DISTINCT FROM + (payload->>'redeemedAt')::TIMESTAMPTZ OR + audit_event->>'action' IS DISTINCT FROM 'recovery.admin_ticket_used' THEN + RAISE EXCEPTION 'administrative recovery redemption is invalid' USING ERRCODE = 'A0003'; + END IF; + consumed := aether_identity.consume_challenge_model( + stored_challenge.id, + stored_challenge.version, + 'consumed', + (payload->>'redeemedAt')::TIMESTAMPTZ + ); + PERFORM aether_identity.insert_session(session_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'redeem_administrative_recovery_ticket', + jsonb_build_object('challenge', consumed, 'recoverySession', session_document, 'auditEvent', audit_event) + ); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V008__device_membership_binding.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V008__device_membership_binding.sql new file mode 100644 index 0000000..f88b33e --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V008__device_membership_binding.sql @@ -0,0 +1,173 @@ +-- Aether identity migration V008 +-- Atomically bind device grant exchange and refresh rotation to the active membership snapshot. + +CREATE OR REPLACE FUNCTION aether_identity.v1_exchange_device_grant(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + family_document JSONB; + access_document JSONB; + refresh_document JSONB; + audit_event JSONB; + stored aether_identity.device_grants%ROWTYPE; + membership aether_identity.memberships%ROWTYPE; + organization aether_identity.organizations%ROWTYPE; + stored_user aether_identity.users%ROWTYPE; + consumed JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'exchange_device_grant'); + family_document := payload->'family'; + access_document := payload->'accessToken'; + refresh_document := payload->'refreshToken'; + audit_event := payload->'auditEvent'; + + SELECT * INTO organization FROM aether_identity.organizations + WHERE id = family_document->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device organization not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO membership FROM aether_identity.memberships + WHERE id = family_document->>'membershipId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device membership not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = family_document->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device user not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO stored FROM aether_identity.device_grants + WHERE id = payload->>'deviceGrantId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device grant not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedDeviceGrantVersion')::BIGINT THEN + RAISE EXCEPTION 'device grant version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'authorized' OR stored.expires_at <= (payload->>'exchangedAt')::TIMESTAMPTZ OR + organization.state <> 'active' OR membership.state <> 'active' OR + stored_user.state <> 'active' OR + membership.version <> (family_document->>'membershipVersion')::BIGINT OR + membership.user_id <> family_document->>'userId' OR + membership.organization_id <> family_document->>'organizationId' OR + family_document->>'membershipId' IS DISTINCT FROM stored.document->>'membershipId' OR + family_document->>'membershipVersion' IS DISTINCT FROM stored.document->>'membershipVersion' OR + family_document->>'deviceGrantId' <> stored.id OR + family_document->>'clientId' IS DISTINCT FROM stored.document->>'clientId' OR + family_document->>'userId' IS DISTINCT FROM stored.document->>'userId' OR + family_document->>'organizationId' IS DISTINCT FROM stored.document->>'organizationId' OR + NOT ((family_document->'capabilities') @> (stored.document->'approvedCapabilities') AND + (family_document->'capabilities') <@ (stored.document->'approvedCapabilities')) OR + (family_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'exchangedAt')::TIMESTAMPTZ OR + access_document->>'familyId' <> family_document->>'id' OR + refresh_document->>'familyId' <> family_document->>'id' OR + (refresh_document->>'rotationCounter')::BIGINT <> 0 OR + (access_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'exchangedAt')::TIMESTAMPTZ OR + (refresh_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'exchangedAt')::TIMESTAMPTZ OR + (access_document->>'expiresAt')::TIMESTAMPTZ > (family_document->>'expiresAt')::TIMESTAMPTZ OR + (refresh_document->>'expiresAt')::TIMESTAMPTZ > (family_document->>'expiresAt')::TIMESTAMPTZ OR + audit_event->>'organizationId' IS DISTINCT FROM family_document->>'organizationId' OR + audit_event->'target'->>'type' IS DISTINCT FROM 'device_grant' OR + audit_event->'target'->>'id' IS DISTINCT FROM stored.id OR + audit_event->>'action' <> 'device_token.issued' THEN + RAISE EXCEPTION 'device grant exchange is invalid' USING ERRCODE = 'A0003'; + END IF; + consumed := stored.document || jsonb_build_object( + 'state', 'consumed', 'version', stored.version + 1, + 'consumedAt', to_jsonb((payload->>'exchangedAt')::TIMESTAMPTZ) + ); + UPDATE aether_identity.device_grants + SET state = 'consumed', version = stored.version + 1, document = consumed + WHERE id = stored.id; + PERFORM aether_identity.insert_device_token_family(family_document); + PERFORM aether_identity.insert_device_access_token(access_document); + PERFORM aether_identity.insert_device_refresh_token(refresh_document); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'exchange_device_grant', + jsonb_build_object( + 'deviceGrant', consumed, 'family', family_document, 'accessToken', access_document, + 'refreshToken', refresh_document, 'auditEvent', audit_event + ) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_rotate_device_refresh_token(p_request JSONB) +RETURNS JSONB LANGUAGE plpgsql AS $$ +DECLARE + payload JSONB; + access_document JSONB; + refresh_document JSONB; + audit_event JSONB; + family_id TEXT; + family_preview JSONB; + family aether_identity.device_token_families%ROWTYPE; + membership aether_identity.memberships%ROWTYPE; + organization aether_identity.organizations%ROWTYPE; + stored_user aether_identity.users%ROWTYPE; + previous aether_identity.device_refresh_tokens%ROWTYPE; + rotated JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'rotate_device_refresh_token'); + access_document := payload->'replacementAccessToken'; + refresh_document := payload->'replacementRefreshToken'; + audit_event := payload->'auditEvent'; + SELECT device_refresh_tokens.family_id INTO family_id + FROM aether_identity.device_refresh_tokens + WHERE id = payload->>'refreshTokenId'; + IF NOT FOUND THEN RAISE EXCEPTION 'device refresh token not found' USING ERRCODE = 'A0012'; END IF; + SELECT document INTO family_preview FROM aether_identity.device_token_families WHERE id = family_id; + IF NOT FOUND THEN RAISE EXCEPTION 'device token family not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO organization FROM aether_identity.organizations + WHERE id = family_preview->>'organizationId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device organization not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO membership FROM aether_identity.memberships + WHERE id = family_preview->>'membershipId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device membership not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = family_preview->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device user not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO family FROM aether_identity.device_token_families + WHERE id = family_id FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device token family not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO previous FROM aether_identity.device_refresh_tokens + WHERE id = payload->>'refreshTokenId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'device refresh token not found' USING ERRCODE = 'A0012'; END IF; + IF previous.version <> (payload->>'expectedRefreshTokenVersion')::BIGINT OR + family.version <> (payload->>'expectedFamilyVersion')::BIGINT THEN + RAISE EXCEPTION 'device token version conflict' USING ERRCODE = 'A0002'; + END IF; + IF previous.family_id <> family.id OR previous.state <> 'active' OR + previous.expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ OR + family.state <> 'active' OR family.expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ OR + organization.state <> 'active' OR membership.state <> 'active' OR + stored_user.state <> 'active' OR + membership.version <> (family.document->>'membershipVersion')::BIGINT OR + membership.user_id <> family.user_id OR membership.organization_id <> family.organization_id OR + membership.id <> family.document->>'membershipId' OR + access_document->>'familyId' <> family.id OR refresh_document->>'familyId' <> family.id OR + (refresh_document->>'rotationCounter')::BIGINT <> previous.rotation_counter + 1 OR + (access_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'rotatedAt')::TIMESTAMPTZ OR + (refresh_document->>'createdAt')::TIMESTAMPTZ <> (payload->>'rotatedAt')::TIMESTAMPTZ OR + (access_document->>'expiresAt')::TIMESTAMPTZ > family.expires_at OR + (refresh_document->>'expiresAt')::TIMESTAMPTZ > family.expires_at OR + audit_event->>'organizationId' IS DISTINCT FROM family.document->>'organizationId' OR + audit_event->'target'->>'type' IS DISTINCT FROM 'device_grant' OR + audit_event->'target'->>'id' IS DISTINCT FROM family.document->>'deviceGrantId' OR + audit_event->>'action' <> 'device_token.refreshed' THEN + RAISE EXCEPTION 'device refresh rotation is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_device_access_token(access_document); + PERFORM aether_identity.insert_device_refresh_token(refresh_document); + rotated := previous.document || jsonb_build_object( + 'state', 'rotated', 'version', previous.version + 1, + 'rotatedToId', refresh_document->'id', + 'consumedAt', to_jsonb((payload->>'rotatedAt')::TIMESTAMPTZ) + ); + UPDATE aether_identity.device_refresh_tokens + SET state = 'rotated', version = previous.version + 1, document = rotated + WHERE id = previous.id; + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'rotate_device_refresh_token', + jsonb_build_object( + 'family', family.document, 'previousRefreshToken', rotated, + 'accessToken', access_document, 'refreshToken', refresh_document, + 'auditEvent', audit_event + ) + ); +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V009__fail_closed_environment_marker.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V009__fail_closed_environment_marker.sql new file mode 100644 index 0000000..8f18059 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V009__fail_closed_environment_marker.sql @@ -0,0 +1,59 @@ +-- Aether identity migration V009 +-- Runtime environment checks are read-only. Provisioning is an explicit deployment action. + +CREATE OR REPLACE FUNCTION aether_identity.assert_environment( + p_environment TEXT, + p_namespace TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + stored aether_identity.environment%ROWTYPE; +BEGIN + SELECT * INTO stored + FROM aether_identity.environment + WHERE singleton = TRUE; + IF NOT FOUND THEN + RAISE EXCEPTION 'identity environment marker is not provisioned' USING ERRCODE = 'A0001'; + END IF; + IF stored.environment <> p_environment OR stored.namespace <> p_namespace THEN + RAISE EXCEPTION 'identity environment mismatch' USING ERRCODE = 'A0001'; + END IF; +END; +$$; + +-- This invoker-rights function is for a short-lived deployment role only. It creates the singleton +-- once and treats an exact existing marker as success; it can never replace another environment. +CREATE OR REPLACE FUNCTION aether_identity.provision_environment( + p_environment TEXT, + p_namespace TEXT +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + stored aether_identity.environment%ROWTYPE; +BEGIN + IF p_environment IS NULL OR + p_environment NOT IN ('development', 'test', 'staging', 'production') OR + p_namespace IS NULL OR + p_namespace !~ '^[a-z][a-z0-9_-]{2,63}$' OR + position(p_environment IN p_namespace) = 0 THEN + RAISE EXCEPTION 'invalid identity environment marker' USING ERRCODE = 'A0014'; + END IF; + + INSERT INTO aether_identity.environment(singleton, environment, namespace) + VALUES (TRUE, p_environment, p_namespace) + ON CONFLICT (singleton) DO NOTHING; + + SELECT * INTO stored + FROM aether_identity.environment + WHERE singleton = TRUE; + IF NOT FOUND OR stored.environment <> p_environment OR stored.namespace <> p_namespace THEN + RAISE EXCEPTION 'identity environment mismatch' USING ERRCODE = 'A0001'; + END IF; +END; +$$; + +-- PostgreSQL grants function execution to PUBLIC by default. Never expose this operation through +-- the normal application or PostgREST role; deployment automation grants it only while provisioning. +REVOKE ALL ON FUNCTION aether_identity.provision_environment(TEXT, TEXT) FROM PUBLIC; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V010__terminal_webauthn_attempts.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V010__terminal_webauthn_attempts.sql new file mode 100644 index 0000000..c953133 --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V010__terminal_webauthn_attempts.sql @@ -0,0 +1,261 @@ +-- Aether identity migration V010 +-- Resolve every valid pending WebAuthn finish in one transaction. Deterministic completion +-- failures roll back credential/session/recovery writes, then commit only FAILED challenge state +-- and a redacted rejection audit. + +CREATE SCHEMA IF NOT EXISTS aether_identity_internal; +REVOKE ALL ON SCHEMA aether_identity_internal FROM PUBLIC; + +-- Preserve the reviewed V001 implementations behind an unexposed schema. PostgREST deployments +-- must expose only aether_identity; aether_identity_internal is never an API schema. +ALTER FUNCTION aether_identity.v1_complete_credential_registration(JSONB) + SET SCHEMA aether_identity_internal; +ALTER FUNCTION aether_identity.v1_complete_credential_authentication(JSONB) + SET SCHEMA aether_identity_internal; +ALTER FUNCTION aether_identity.v1_quarantine_credential_authentication(JSONB) + SET SCHEMA aether_identity_internal; +ALTER FUNCTION aether_identity.v1_complete_recovery_enrollment(JSONB) + SET SCHEMA aether_identity_internal; + +REVOKE ALL ON FUNCTION aether_identity_internal.v1_complete_credential_registration(JSONB) FROM PUBLIC; +REVOKE ALL ON FUNCTION aether_identity_internal.v1_complete_credential_authentication(JSONB) FROM PUBLIC; +REVOKE ALL ON FUNCTION aether_identity_internal.v1_quarantine_credential_authentication(JSONB) FROM PUBLIC; +REVOKE ALL ON FUNCTION aether_identity_internal.v1_complete_recovery_enrollment(JSONB) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity_internal.web_authn_store_error_code(p_sqlstate TEXT) +RETURNS TEXT +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT CASE p_sqlstate + WHEN 'A0002' THEN 'version_conflict' + WHEN 'A0003' THEN 'invalid_transition' + WHEN 'A0004' THEN 'last_owner' + WHEN 'A0005' THEN 'replay_detected' + WHEN 'A0006' THEN 'idempotency_conflict' + WHEN 'A0009' THEN 'session_not_active' + WHEN 'A0010' THEN 'session_expired' + WHEN 'A0011' THEN 'recovery_code_not_active' + WHEN 'A0012' THEN 'not_found' + WHEN 'A0013' THEN 'already_exists' + WHEN '23505' THEN 'unique_constraint' + ELSE 'invalid_transition' + END; +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.web_authn_store_error_code(TEXT) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity_internal.resolve_web_authn_attempt( + p_request JSONB, + p_operation TEXT +) RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + payload JSONB; + attempted_at TIMESTAMPTZ; + rejection_audit JSONB; + stored_challenge aether_identity.challenges%ROWTYPE; + completion_response JSONB; + failed_challenge JSONB; + rejected_sqlstate TEXT; +BEGIN + IF p_operation NOT IN ( + 'complete_credential_registration', + 'complete_credential_authentication', + 'quarantine_credential_authentication', + 'complete_recovery_enrollment' + ) THEN + RAISE EXCEPTION 'invalid WebAuthn completion operation' USING ERRCODE = 'A0014'; + END IF; + + payload := aether_identity.rpc_payload(p_request, p_operation); + attempted_at := CASE p_operation + WHEN 'complete_credential_registration' THEN + (payload#>>'{auditEvent,occurredAt}')::TIMESTAMPTZ + WHEN 'complete_credential_authentication' THEN + (payload->>'authenticatedAt')::TIMESTAMPTZ + WHEN 'quarantine_credential_authentication' THEN + (payload->>'detectedAt')::TIMESTAMPTZ + WHEN 'complete_recovery_enrollment' THEN + (payload->>'completedAt')::TIMESTAMPTZ + END; + rejection_audit := payload->'rejectionAuditEvent'; + + SELECT * INTO stored_challenge + FROM aether_identity.challenges + WHERE id = payload->>'challengeId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'WebAuthn challenge not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_challenge.version <> (payload->>'expectedChallengeVersion')::BIGINT THEN + RAISE EXCEPTION 'WebAuthn challenge version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_challenge.state <> 'pending' THEN + RAISE EXCEPTION 'WebAuthn challenge is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored_challenge.expires_at <= attempted_at THEN + RAISE EXCEPTION 'WebAuthn challenge expired' USING ERRCODE = 'A0008'; + END IF; + IF attempted_at IS NULL OR jsonb_typeof(rejection_audit) IS DISTINCT FROM 'object' OR + rejection_audit->>'action' IS DISTINCT FROM 'webauthn.ceremony_rejected' OR + rejection_audit->>'outcome' IS DISTINCT FROM 'denied' OR + rejection_audit#>>'{target,type}' IS DISTINCT FROM 'challenge' OR + rejection_audit#>>'{target,id}' IS DISTINCT FROM stored_challenge.id OR + rejection_audit->>'reasonCode' IS DISTINCT FROM 'webauthn_store_rejected' OR + (rejection_audit->>'occurredAt')::TIMESTAMPTZ IS DISTINCT FROM attempted_at THEN + RAISE EXCEPTION 'invalid WebAuthn rejection audit' USING ERRCODE = 'A0014'; + END IF; + + BEGIN + EXECUTE format( + 'SELECT aether_identity_internal.%I($1)', + 'v1_' || p_operation + ) INTO completion_response USING p_request; + RETURN aether_identity.rpc_success( + p_operation, + jsonb_build_object( + 'completion', completion_response->'result', + 'rejection', 'null'::JSONB + ) + ); + EXCEPTION + WHEN SQLSTATE 'A0002' OR SQLSTATE 'A0003' OR SQLSTATE 'A0004' OR + SQLSTATE 'A0005' OR SQLSTATE 'A0006' OR SQLSTATE 'A0009' OR + SQLSTATE 'A0010' OR SQLSTATE 'A0011' OR SQLSTATE 'A0012' OR + SQLSTATE 'A0013' OR integrity_constraint_violation OR data_exception THEN + GET STACKED DIAGNOSTICS rejected_sqlstate = RETURNED_SQLSTATE; + END; + + failed_challenge := aether_identity.consume_challenge_model( + stored_challenge.id, + stored_challenge.version, + 'failed', + attempted_at + ); + PERFORM aether_identity.record_audit(rejection_audit); + RETURN aether_identity.rpc_success( + p_operation, + jsonb_build_object( + 'completion', 'null'::JSONB, + 'rejection', jsonb_build_object( + 'challenge', failed_challenge, + 'error', jsonb_build_object( + 'code', aether_identity_internal.web_authn_store_error_code(rejected_sqlstate), + 'retryable', FALSE + ), + 'auditEvent', rejection_audit + ) + ) + ); +END; +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.resolve_web_authn_attempt(JSONB, TEXT) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity.v1_complete_credential_registration(p_request JSONB) +RETURNS JSONB +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ + SELECT aether_identity_internal.resolve_web_authn_attempt( + p_request, + 'complete_credential_registration' + ); +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_complete_credential_authentication(p_request JSONB) +RETURNS JSONB +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ + SELECT aether_identity_internal.resolve_web_authn_attempt( + p_request, + 'complete_credential_authentication' + ); +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_quarantine_credential_authentication(p_request JSONB) +RETURNS JSONB +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ + SELECT aether_identity_internal.resolve_web_authn_attempt( + p_request, + 'quarantine_credential_authentication' + ); +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_complete_recovery_enrollment(p_request JSONB) +RETURNS JSONB +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ + SELECT aether_identity_internal.resolve_web_authn_attempt( + p_request, + 'complete_recovery_enrollment' + ); +$$; + +COMMENT ON SCHEMA aether_identity_internal IS + 'Aether implementation functions; never expose this schema through PostgREST'; + +DO $$ +DECLARE + wrapper_name TEXT; + internal_function_name TEXT; + wrapper_security_definer BOOLEAN; + wrapper_config TEXT[]; +BEGIN + IF has_schema_privilege('public', 'aether_identity_internal', 'USAGE') OR + has_function_privilege( + 'public', + 'aether_identity_internal.resolve_web_authn_attempt(jsonb,text)', + 'EXECUTE' + ) OR + has_function_privilege( + 'public', + 'aether_identity_internal.web_authn_store_error_code(text)', + 'EXECUTE' + ) THEN + RAISE EXCEPTION 'internal WebAuthn functions remain publicly reachable'; + END IF; + + FOREACH internal_function_name IN ARRAY ARRAY[ + 'v1_complete_credential_registration', + 'v1_complete_credential_authentication', + 'v1_quarantine_credential_authentication', + 'v1_complete_recovery_enrollment' + ] LOOP + IF has_function_privilege( + 'public', + format('aether_identity_internal.%I(jsonb)', internal_function_name), + 'EXECUTE' + ) THEN + RAISE EXCEPTION 'internal WebAuthn function % remains publicly reachable', + internal_function_name; + END IF; + END LOOP; + + FOREACH wrapper_name IN ARRAY ARRAY[ + 'v1_complete_credential_registration', + 'v1_complete_credential_authentication', + 'v1_quarantine_credential_authentication', + 'v1_complete_recovery_enrollment' + ] LOOP + SELECT p.prosecdef, p.proconfig + INTO wrapper_security_definer, wrapper_config + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'aether_identity' AND p.proname = wrapper_name + AND pg_catalog.pg_get_function_identity_arguments(p.oid) = 'p_request jsonb'; + IF NOT FOUND OR NOT wrapper_security_definer OR + wrapper_config IS DISTINCT FROM ARRAY['search_path=pg_catalog']::TEXT[] THEN + RAISE EXCEPTION 'WebAuthn wrapper % is not locked SECURITY DEFINER', wrapper_name; + END IF; + END LOOP; +END; +$$; diff --git a/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V011__federation_provider_lifecycle.sql b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V011__federation_provider_lifecycle.sql new file mode 100644 index 0000000..84cf9ba --- /dev/null +++ b/aether-auth-postgresql/src/commonMain/resources/db/aether-identity/V011__federation_provider_lifecycle.sql @@ -0,0 +1,1070 @@ +-- Aether identity migration V011 +-- Federation providers are controlled by one tenant-route row. Disabling advances a logical +-- session epoch instead of updating an unbounded set of sessions. Every command that creates or +-- consumes federation state validates the exact enabled-provider lease in the same transaction. + +CREATE TABLE aether_identity.federation_provider_controls ( + organization_id TEXT NOT NULL REFERENCES aether_identity.organizations(id), + provider_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('oidc', 'saml')), + storage_key TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('enabled', 'disabled')), + session_epoch BIGINT NOT NULL CHECK (session_epoch >= 0), + version BIGINT NOT NULL CHECK (version >= 0), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + disabled_at TIMESTAMPTZ, + disabled_reason_code TEXT, + document JSONB NOT NULL, + PRIMARY KEY (organization_id, provider_id), + CHECK (provider_id ~ '^[a-z0-9][a-z0-9_-]{0,62}$'), + CHECK ( + (kind = 'oidc' AND storage_key ~ '^oidc\.[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$') OR + (kind = 'saml' AND storage_key ~ '^saml\.[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$') + ), + CHECK (updated_at >= created_at), + CHECK ( + (state = 'enabled' AND disabled_at IS NULL AND disabled_reason_code IS NULL) OR + (state = 'disabled' AND disabled_at IS NOT NULL AND disabled_at = updated_at AND + disabled_reason_code IS NOT NULL AND + length(disabled_reason_code) BETWEEN 1 AND 200 AND + disabled_reason_code ~ '[^[:space:]]') + ), + CHECK (jsonb_typeof(document) = 'object'), + CHECK (document->>'organizationId' IS NOT DISTINCT FROM organization_id), + CHECK (document->>'providerId' IS NOT DISTINCT FROM provider_id), + CHECK (document->>'kind' IS NOT DISTINCT FROM kind), + CHECK (document->>'storageKey' IS NOT DISTINCT FROM storage_key), + CHECK (document->>'state' IS NOT DISTINCT FROM state), + CHECK ((document->>'sessionEpoch')::BIGINT IS NOT DISTINCT FROM session_epoch), + CHECK ((document->>'version')::BIGINT IS NOT DISTINCT FROM version), + CHECK ((document->>'createdAt')::TIMESTAMPTZ IS NOT DISTINCT FROM created_at), + CHECK ((document->>'updatedAt')::TIMESTAMPTZ IS NOT DISTINCT FROM updated_at), + CHECK ((document->>'disabledAt')::TIMESTAMPTZ IS NOT DISTINCT FROM disabled_at), + CHECK ((document->>'disabledReasonCode') IS NOT DISTINCT FROM disabled_reason_code) +); + +COMMENT ON TABLE aether_identity.federation_provider_controls IS + 'Canonical tenant federation lifecycle state; session invalidation uses session_epoch'; + +ALTER TABLE aether_identity.sessions + ADD COLUMN federation_provider_session_epoch BIGINT CHECK ( + federation_provider_session_epoch IS NULL OR federation_provider_session_epoch >= 0 + ); + +-- V002 established provenance before the provider control plane existed. Preserve its federated +-- sessions as epoch zero records; because no provider row is backfilled, they fail closed until an +-- administrator explicitly creates and enables the provider. Non-federated sessions remain null. +UPDATE aether_identity.sessions + SET federation_provider_session_epoch = 0, + document = jsonb_set(document, '{federationProviderSessionEpoch}', '0'::JSONB, TRUE) + WHERE authentication_method IN ('oidc', 'saml'); + +ALTER TABLE aether_identity.sessions + ADD CONSTRAINT sessions_federation_provider_epoch_document_check CHECK ( + (document->>'federationProviderSessionEpoch')::BIGINT IS NOT DISTINCT FROM + federation_provider_session_epoch + ), + DROP CONSTRAINT sessions_federation_provenance_check, + ADD CONSTRAINT sessions_federation_provenance_check CHECK ( + ( + authentication_method IN ('oidc', 'saml') AND + federation_organization_id IS NOT NULL AND + federation_provider_key IS NOT NULL AND + length(federation_provider_key) BETWEEN 1 AND 512 AND + federation_provider_key ~ '[^[:space:]]' AND + federation_provider_session_epoch IS NOT NULL AND + external_identity_id IS NOT NULL + ) OR ( + authentication_method NOT IN ('oidc', 'saml') AND + federation_organization_id IS NULL AND + federation_provider_key IS NULL AND + federation_provider_session_epoch IS NULL AND + external_identity_id IS NULL + ) + ); + +CREATE OR REPLACE FUNCTION aether_identity_internal.federation_provider_lease( + p_document JSONB +) RETURNS JSONB +LANGUAGE sql +IMMUTABLE +STRICT +AS $$ + SELECT jsonb_build_object( + 'organizationId', p_document->'organizationId', + 'kind', p_document->'kind', + 'providerId', p_document->'providerId', + 'storageKey', p_document->'storageKey', + 'sessionEpoch', p_document->'sessionEpoch', + 'version', p_document->'version' + ); +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.federation_provider_lease(JSONB) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity_internal.require_federation_provider_lease( + p_lease JSONB +) RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + organization aether_identity.organizations%ROWTYPE; + stored aether_identity.federation_provider_controls%ROWTYPE; +BEGIN + IF jsonb_typeof(p_lease) IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'federation provider lease is unavailable' USING ERRCODE = 'A0015'; + END IF; + + SELECT * INTO organization + FROM aether_identity.organizations + WHERE id = p_lease->>'organizationId' + FOR KEY SHARE; + IF NOT FOUND THEN + RAISE EXCEPTION 'federation provider lease is unavailable' USING ERRCODE = 'A0015'; + END IF; + + SELECT * INTO stored + FROM aether_identity.federation_provider_controls + WHERE organization_id = p_lease->>'organizationId' + AND provider_id = p_lease->>'providerId' + FOR UPDATE; + + IF NOT FOUND OR stored.state <> 'enabled' OR + stored.kind IS DISTINCT FROM p_lease->>'kind' OR + stored.storage_key IS DISTINCT FROM p_lease->>'storageKey' OR + stored.session_epoch::TEXT IS DISTINCT FROM p_lease->>'sessionEpoch' OR + stored.version::TEXT IS DISTINCT FROM p_lease->>'version' THEN + RAISE EXCEPTION 'federation provider lease is unavailable' USING ERRCODE = 'A0015'; + END IF; + + RETURN aether_identity_internal.federation_provider_lease(stored.document); +END; +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.require_federation_provider_lease(JSONB) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity_internal.require_federated_session( + p_session JSONB +) RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + method TEXT := p_session->>'authenticationMethod'; + expected_kind TEXT; + organization aether_identity.organizations%ROWTYPE; + stored aether_identity.federation_provider_controls%ROWTYPE; +BEGIN + expected_kind := CASE method WHEN 'oidc' THEN 'oidc' WHEN 'saml' THEN 'saml' ELSE NULL END; + IF expected_kind IS NULL THEN + IF p_session->>'federationOrganizationId' IS NOT NULL OR + p_session->>'federationProviderKey' IS NOT NULL OR + p_session->>'federationProviderSessionEpoch' IS NOT NULL OR + p_session->>'externalIdentityId' IS NOT NULL THEN + RAISE EXCEPTION 'non-federated session contains federation provenance' + USING ERRCODE = 'A0003'; + END IF; + RETURN; + END IF; + + SELECT * INTO organization + FROM aether_identity.organizations + WHERE id = p_session->>'federationOrganizationId' + FOR KEY SHARE; + IF NOT FOUND THEN + RAISE EXCEPTION 'federated session provider is unavailable' USING ERRCODE = 'A0015'; + END IF; + + SELECT * INTO stored + FROM aether_identity.federation_provider_controls + WHERE storage_key = p_session->>'federationProviderKey' + FOR UPDATE; + IF NOT FOUND OR stored.state <> 'enabled' OR + stored.organization_id IS DISTINCT FROM p_session->>'federationOrganizationId' OR + stored.kind IS DISTINCT FROM expected_kind OR + stored.session_epoch::TEXT IS DISTINCT FROM p_session->>'federationProviderSessionEpoch' THEN + RAISE EXCEPTION 'federated session provider is unavailable' USING ERRCODE = 'A0015'; + END IF; +END; +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.require_federated_session(JSONB) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity.insert_session(p_session JSONB) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + stored_user aether_identity.users%ROWTYPE; + predecessor aether_identity.sessions%ROWTYPE; +BEGIN + SELECT * INTO stored_user FROM aether_identity.users + WHERE id = p_session->>'userId' FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'session user not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_user.state <> 'active' OR + stored_user.session_epoch <> (p_session->>'userSessionEpoch')::BIGINT OR + p_session->>'state' <> 'active' OR + (p_session->>'version')::BIGINT <> 0 THEN + RAISE EXCEPTION 'new session is invalid' USING ERRCODE = 'A0003'; + END IF; + + -- A passkey step-up may replace a federated session without retaining federation provenance, + -- but the predecessor's provider still has to be current at the instant of rotation. + IF p_session->>'rotatedFromId' IS NOT NULL THEN + SELECT * INTO predecessor + FROM aether_identity.sessions + WHERE id = p_session->>'rotatedFromId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'session predecessor not found' USING ERRCODE = 'A0012'; + END IF; + PERFORM aether_identity_internal.require_federated_session(predecessor.document); + END IF; + + PERFORM aether_identity_internal.require_federated_session(p_session); + INSERT INTO aether_identity.sessions( + id, family_id, user_id, + token_digest_algorithm, token_digest_encoded, token_digest_key_version, + csrf_digest_algorithm, csrf_digest_encoded, csrf_digest_key_version, + authentication_method, federation_organization_id, federation_provider_key, + federation_provider_session_epoch, external_identity_id, state, user_session_epoch, version, + idle_expires_at, absolute_expires_at, document + ) VALUES ( + p_session->>'id', p_session->>'familyId', p_session->>'userId', + p_session#>>'{tokenDigest,algorithm}', p_session#>>'{tokenDigest,encoded}', + p_session#>>'{tokenDigest,keyVersion}', + p_session#>>'{csrfDigest,algorithm}', p_session#>>'{csrfDigest,encoded}', + p_session#>>'{csrfDigest,keyVersion}', + p_session->>'authenticationMethod', p_session->>'federationOrganizationId', + p_session->>'federationProviderKey', (p_session->>'federationProviderSessionEpoch')::BIGINT, + p_session->>'externalIdentityId', p_session->>'state', + (p_session->>'userSessionEpoch')::BIGINT, (p_session->>'version')::BIGINT, + (p_session->>'idleExpiresAt')::TIMESTAMPTZ, + (p_session->>'absoluteExpiresAt')::TIMESTAMPTZ, p_session + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_federation_provider_control(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'find_federation_provider_control'); + SELECT document INTO result + FROM aether_identity.federation_provider_controls + WHERE organization_id = payload->>'organizationId' + AND provider_id = payload->>'providerId'; + RETURN aether_identity.rpc_success('find_federation_provider_control', result); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_find_federation_provider_control_by_storage_key( + p_request JSONB +) RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE payload JSONB; result JSONB; +BEGIN + payload := aether_identity.rpc_payload( + p_request, + 'find_federation_provider_control_by_storage_key' + ); + SELECT document INTO result + FROM aether_identity.federation_provider_controls + WHERE storage_key = payload->>'storageKey'; + RETURN aether_identity.rpc_success( + 'find_federation_provider_control_by_storage_key', + result + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_acquire_federation_provider_lease(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + organization aether_identity.organizations%ROWTYPE; + stored aether_identity.federation_provider_controls%ROWTYPE; + storage_owner aether_identity.federation_provider_controls%ROWTYPE; + initial_document JSONB; + lease JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'acquire_federation_provider_lease'); + SELECT * INTO organization + FROM aether_identity.organizations + WHERE id = payload->>'organizationId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'federation provider organization not found' USING ERRCODE = 'A0012'; + END IF; + IF organization.state <> 'active' THEN + RAISE EXCEPTION 'federation provider organization is not active' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored + FROM aether_identity.federation_provider_controls + WHERE organization_id = payload->>'organizationId' + AND provider_id = payload->>'providerId' + FOR UPDATE; + IF NOT FOUND THEN + SELECT * INTO storage_owner + FROM aether_identity.federation_provider_controls + WHERE storage_key = payload->>'storageKey' + FOR UPDATE; + IF FOUND THEN + RAISE unique_violation USING MESSAGE = 'federation provider mapping already exists'; + END IF; + initial_document := jsonb_build_object( + 'organizationId', payload->'organizationId', + 'kind', payload->'kind', + 'providerId', payload->'providerId', + 'storageKey', payload->'storageKey', + 'state', 'enabled', + 'sessionEpoch', 0, + 'version', 0, + 'createdAt', payload->'acquiredAt', + 'updatedAt', payload->'acquiredAt', + 'disabledAt', 'null'::JSONB, + 'disabledReasonCode', 'null'::JSONB + ); + INSERT INTO aether_identity.federation_provider_controls( + organization_id, provider_id, kind, storage_key, state, session_epoch, version, + created_at, updated_at, disabled_at, disabled_reason_code, document + ) VALUES ( + payload->>'organizationId', payload->>'providerId', payload->>'kind', + payload->>'storageKey', 'enabled', 0, 0, + (payload->>'acquiredAt')::TIMESTAMPTZ, (payload->>'acquiredAt')::TIMESTAMPTZ, + NULL, NULL, initial_document + ) RETURNING * INTO stored; + END IF; + IF stored.kind IS DISTINCT FROM payload->>'kind' OR + stored.storage_key IS DISTINCT FROM payload->>'storageKey' THEN + RAISE unique_violation USING MESSAGE = 'federation provider mapping already exists'; + END IF; + IF stored.state <> 'enabled' THEN + RAISE EXCEPTION 'federation provider is disabled' USING ERRCODE = 'A0015'; + END IF; + lease := aether_identity_internal.federation_provider_lease(stored.document); + RETURN aether_identity.rpc_success('acquire_federation_provider_lease', lease); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_validate_federation_provider_lease(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE payload JSONB; lease JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'validate_federation_provider_lease'); + lease := aether_identity_internal.require_federation_provider_lease(payload); + RETURN aether_identity.rpc_success('validate_federation_provider_lease', lease); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_compare_and_set_federation_provider_state( + p_request JSONB +) RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + replacement JSONB; + audit_event JSONB; + expected_version BIGINT; + expected_version_absent BOOLEAN; + organization aether_identity.organizations%ROWTYPE; + stored aether_identity.federation_provider_controls%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload( + p_request, + 'compare_and_set_federation_provider_state' + ); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + expected_version_absent := payload->'expectedVersion' IS NULL OR + payload->'expectedVersion' = 'null'::JSONB; + IF NOT expected_version_absent THEN + BEGIN + expected_version := (payload->>'expectedVersion')::BIGINT; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'federation provider expected version is invalid' + USING ERRCODE = 'A0003'; + END; + END IF; + + IF jsonb_typeof(replacement) IS DISTINCT FROM 'object' OR + jsonb_typeof(audit_event) IS DISTINCT FROM 'object' OR + replacement->>'organizationId' IS NULL OR replacement->>'providerId' IS NULL OR + replacement->>'storageKey' IS NULL THEN + RAISE EXCEPTION 'federation provider transition is invalid' USING ERRCODE = 'A0003'; + END IF; + + SELECT * INTO organization + FROM aether_identity.organizations + WHERE id = replacement->>'organizationId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'federation provider organization not found' USING ERRCODE = 'A0012'; + END IF; + IF organization.state <> 'active' THEN + RAISE EXCEPTION 'federation provider organization is not active' USING ERRCODE = 'A0003'; + END IF; + + IF audit_event->>'organizationId' IS DISTINCT FROM replacement->>'organizationId' OR + audit_event#>>'{target,type}' IS DISTINCT FROM 'federation_provider' OR + audit_event#>>'{target,id}' IS DISTINCT FROM replacement->>'storageKey' OR + audit_event->>'outcome' IS DISTINCT FROM 'succeeded' OR + (audit_event->>'occurredAt')::TIMESTAMPTZ IS DISTINCT FROM + (replacement->>'updatedAt')::TIMESTAMPTZ OR + audit_event->>'action' IS DISTINCT FROM ( + CASE replacement->>'state' + WHEN 'enabled' THEN 'federation_provider.enabled' + WHEN 'disabled' THEN 'federation_provider.disabled' + ELSE NULL + END + ) OR + (replacement->>'state' = 'disabled' AND + audit_event->>'reasonCode' IS DISTINCT FROM replacement->>'disabledReasonCode') THEN + RAISE EXCEPTION 'federation provider audit is invalid' USING ERRCODE = 'A0003'; + END IF; + + IF expected_version_absent THEN + IF replacement->>'state' IS DISTINCT FROM 'disabled' OR + (replacement->>'version')::BIGINT IS DISTINCT FROM 0 OR + (replacement->>'sessionEpoch')::BIGINT IS DISTINCT FROM 1 OR + (replacement->>'createdAt')::TIMESTAMPTZ IS DISTINCT FROM + (replacement->>'updatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'initial federation provider state is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored + FROM aether_identity.federation_provider_controls + WHERE organization_id = replacement->>'organizationId' + AND provider_id = replacement->>'providerId' + FOR UPDATE; + IF FOUND THEN + IF stored.kind IS DISTINCT FROM replacement->>'kind' OR + stored.storage_key IS DISTINCT FROM replacement->>'storageKey' THEN + RAISE unique_violation USING MESSAGE = 'federation provider mapping already exists'; + END IF; + RAISE EXCEPTION 'federation provider version conflict' USING ERRCODE = 'A0002'; + END IF; + SELECT * INTO stored + FROM aether_identity.federation_provider_controls + WHERE storage_key = replacement->>'storageKey' + FOR UPDATE; + IF FOUND THEN + RAISE unique_violation USING MESSAGE = 'federation provider mapping already exists'; + END IF; + INSERT INTO aether_identity.federation_provider_controls( + organization_id, provider_id, kind, storage_key, state, session_epoch, version, + created_at, updated_at, disabled_at, disabled_reason_code, document + ) VALUES ( + replacement->>'organizationId', replacement->>'providerId', replacement->>'kind', + replacement->>'storageKey', replacement->>'state', + (replacement->>'sessionEpoch')::BIGINT, (replacement->>'version')::BIGINT, + (replacement->>'createdAt')::TIMESTAMPTZ, + (replacement->>'updatedAt')::TIMESTAMPTZ, + (replacement->>'disabledAt')::TIMESTAMPTZ, + replacement->>'disabledReasonCode', replacement + ); + ELSE + SELECT * INTO stored + FROM aether_identity.federation_provider_controls + WHERE organization_id = replacement->>'organizationId' + AND provider_id = replacement->>'providerId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'federation provider not found' USING ERRCODE = 'A0012'; + END IF; + IF stored.version <> expected_version THEN + RAISE EXCEPTION 'federation provider version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.kind IS DISTINCT FROM replacement->>'kind' OR + stored.storage_key IS DISTINCT FROM replacement->>'storageKey' THEN + RAISE unique_violation USING MESSAGE = 'federation provider mapping already exists'; + END IF; + IF stored.created_at IS DISTINCT FROM (replacement->>'createdAt')::TIMESTAMPTZ OR + (replacement->>'version')::BIGINT IS DISTINCT FROM expected_version + 1 OR + (replacement->>'updatedAt')::TIMESTAMPTZ < stored.updated_at OR + stored.state = replacement->>'state' OR + (stored.state = 'enabled' AND ( + replacement->>'state' IS DISTINCT FROM 'disabled' OR + (replacement->>'sessionEpoch')::BIGINT IS DISTINCT FROM stored.session_epoch + 1 + )) OR + (stored.state = 'disabled' AND ( + replacement->>'state' IS DISTINCT FROM 'enabled' OR + (replacement->>'sessionEpoch')::BIGINT IS DISTINCT FROM stored.session_epoch + )) THEN + RAISE EXCEPTION 'federation provider transition is invalid' USING ERRCODE = 'A0003'; + END IF; + UPDATE aether_identity.federation_provider_controls + SET state = replacement->>'state', + session_epoch = (replacement->>'sessionEpoch')::BIGINT, + version = (replacement->>'version')::BIGINT, + updated_at = (replacement->>'updatedAt')::TIMESTAMPTZ, + disabled_at = (replacement->>'disabledAt')::TIMESTAMPTZ, + disabled_reason_code = replacement->>'disabledReasonCode', + document = replacement + WHERE organization_id = stored.organization_id AND provider_id = stored.provider_id; + END IF; + + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'compare_and_set_federation_provider_state', + jsonb_build_object('control', replacement, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_create_challenge(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + challenge JSONB; + command_lease JSONB; + stored_lease JSONB; + challenge_user aether_identity.users%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'create_challenge'); + challenge := payload->'challenge'; + command_lease := COALESCE(payload->'federationProviderLease', 'null'::JSONB); + stored_lease := COALESCE(challenge->'federationProviderLease', 'null'::JSONB); + IF challenge->>'state' <> 'pending' OR + (challenge->>'version')::BIGINT <> 0 OR + (challenge->>'attemptCount')::INTEGER <> 0 OR + command_lease IS DISTINCT FROM stored_lease THEN + RAISE EXCEPTION 'new challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + IF challenge->>'purpose' = 'external_identity_link' THEN + IF stored_lease = 'null'::JSONB OR + challenge->>'organizationId' IS DISTINCT FROM stored_lease->>'organizationId' THEN + RAISE EXCEPTION 'external identity challenge is invalid' USING ERRCODE = 'A0003'; + END IF; + IF challenge->>'userId' IS NOT NULL THEN + SELECT * INTO challenge_user + FROM aether_identity.users + WHERE id = challenge->>'userId' + FOR KEY SHARE; + IF NOT FOUND THEN + RAISE EXCEPTION 'external identity challenge user not found' + USING ERRCODE = 'A0012'; + END IF; + END IF; + PERFORM aether_identity_internal.require_federation_provider_lease(stored_lease); + ELSIF stored_lease <> 'null'::JSONB THEN + RAISE EXCEPTION 'non-federated challenge contains provider lease' USING ERRCODE = 'A0003'; + END IF; + INSERT INTO aether_identity.challenges( + id, purpose, + challenge_digest_algorithm, challenge_digest_encoded, challenge_digest_key_version, + binding_digest_algorithm, binding_digest_encoded, binding_digest_key_version, + payload_digest_algorithm, payload_digest_encoded, payload_digest_key_version, + user_id, organization_id, state, version, expires_at, document + ) VALUES ( + challenge->>'id', challenge->>'purpose', + challenge#>>'{challengeDigest,algorithm}', challenge#>>'{challengeDigest,encoded}', + challenge#>>'{challengeDigest,keyVersion}', challenge#>>'{bindingDigest,algorithm}', + challenge#>>'{bindingDigest,encoded}', challenge#>>'{bindingDigest,keyVersion}', + challenge#>>'{payloadDigest,algorithm}', challenge#>>'{payloadDigest,encoded}', + challenge#>>'{payloadDigest,keyVersion}', NULLIF(challenge->>'userId', ''), + NULLIF(challenge->>'organizationId', ''), challenge->>'state', + (challenge->>'version')::BIGINT, (challenge->>'expiresAt')::TIMESTAMPTZ, challenge + ); + RETURN aether_identity.rpc_success('create_challenge', challenge); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_consume_challenge(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + stored aether_identity.challenges%ROWTYPE; + command_lease JSONB; + stored_lease JSONB; + challenge JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'consume_challenge'); + SELECT * INTO stored + FROM aether_identity.challenges + WHERE id = payload->>'challengeId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'challenge not found' USING ERRCODE = 'A0012'; + END IF; + command_lease := COALESCE(payload->'federationProviderLease', 'null'::JSONB); + stored_lease := COALESCE(stored.document->'federationProviderLease', 'null'::JSONB); + IF command_lease IS DISTINCT FROM stored_lease THEN + RAISE EXCEPTION 'challenge provider lease changed' USING ERRCODE = 'A0003'; + END IF; + IF stored.purpose = 'external_identity_link' THEN + IF stored_lease = 'null'::JSONB THEN + RAISE EXCEPTION 'external identity challenge has no provider lease' + USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity_internal.require_federation_provider_lease(stored_lease); + ELSIF stored_lease <> 'null'::JSONB THEN + RAISE EXCEPTION 'non-federated challenge contains provider lease' USING ERRCODE = 'A0003'; + END IF; + challenge := aether_identity.consume_challenge_model( + payload->>'challengeId', + (payload->>'expectedVersion')::BIGINT, + payload->>'terminalState', + (payload->>'consumedAt')::TIMESTAMPTZ + ); + IF payload->'auditEvent' IS NOT NULL AND payload->'auditEvent' <> 'null'::JSONB THEN + PERFORM aether_identity.record_audit(payload->'auditEvent'); + END IF; + RETURN aether_identity.rpc_success('consume_challenge', challenge); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_touch_identity_session(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + expected_version BIGINT; + requested_last_used_at TIMESTAMPTZ; + requested_idle_expires_at TIMESTAMPTZ; + stored aether_identity.sessions%ROWTYPE; + replacement JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'touch_identity_session'); + IF payload->>'sessionId' IS NULL OR payload->>'expectedVersion' IS NULL OR + payload->>'lastUsedAt' IS NULL OR payload->>'idleExpiresAt' IS NULL THEN + RAISE EXCEPTION 'identity session touch request is invalid' USING ERRCODE = 'A0003'; + END IF; + BEGIN + expected_version := (payload->>'expectedVersion')::BIGINT; + requested_last_used_at := (payload->>'lastUsedAt')::TIMESTAMPTZ; + requested_idle_expires_at := (payload->>'idleExpiresAt')::TIMESTAMPTZ; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'identity session touch request is invalid' USING ERRCODE = 'A0003'; + END; + IF expected_version < 0 THEN + RAISE EXCEPTION 'identity session touch version is invalid' USING ERRCODE = 'A0003'; + END IF; + SELECT * INTO stored FROM aether_identity.sessions + WHERE id = payload->>'sessionId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'session not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> expected_version THEN + RAISE EXCEPTION 'session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' THEN + RAISE EXCEPTION 'session not active' USING ERRCODE = 'A0009'; + END IF; + PERFORM aether_identity_internal.require_federated_session(stored.document); + IF requested_last_used_at >= stored.idle_expires_at OR + requested_last_used_at >= stored.absolute_expires_at THEN + RAISE EXCEPTION 'session expired' USING ERRCODE = 'A0010'; + END IF; + IF requested_last_used_at < (stored.document->>'lastUsedAt')::TIMESTAMPTZ OR + requested_idle_expires_at < requested_last_used_at OR + requested_idle_expires_at > stored.absolute_expires_at THEN + RAISE EXCEPTION 'identity session touch is invalid' USING ERRCODE = 'A0003'; + END IF; + replacement := stored.document || jsonb_build_object( + 'lastUsedAt', payload->'lastUsedAt', + 'idleExpiresAt', payload->'idleExpiresAt', + 'version', stored.version + 1 + ); + UPDATE aether_identity.sessions + SET version = stored.version + 1, + idle_expires_at = requested_idle_expires_at, + document = replacement + WHERE id = stored.id; + RETURN aether_identity.rpc_success('touch_identity_session', replacement); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_rotate_session(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + stored aether_identity.sessions%ROWTYPE; + previous JSONB; + replacement JSONB; + audit_event JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'rotate_session'); + replacement := payload->'replacement'; + audit_event := payload->'auditEvent'; + PERFORM 1 FROM aether_identity.users + WHERE id = replacement->>'userId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'session user not found' USING ERRCODE = 'A0012'; END IF; + SELECT * INTO stored FROM aether_identity.sessions + WHERE id = payload->>'sessionId' FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'session not found' USING ERRCODE = 'A0012'; END IF; + IF stored.version <> (payload->>'expectedVersion')::BIGINT THEN + RAISE EXCEPTION 'session version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored.state <> 'active' THEN + RAISE EXCEPTION 'session not active' USING ERRCODE = 'A0009'; + END IF; + PERFORM aether_identity_internal.require_federated_session(stored.document); + IF stored.authentication_method IN ('oidc', 'saml') AND ( + replacement->>'authenticationMethod' IS DISTINCT FROM stored.authentication_method OR + replacement->>'federationOrganizationId' IS DISTINCT FROM stored.federation_organization_id OR + replacement->>'federationProviderKey' IS DISTINCT FROM stored.federation_provider_key OR + replacement->>'federationProviderSessionEpoch' IS DISTINCT FROM + stored.federation_provider_session_epoch::TEXT OR + replacement->>'externalIdentityId' IS DISTINCT FROM stored.external_identity_id + ) THEN + RAISE EXCEPTION 'federated session rotation changed provenance' USING ERRCODE = 'A0003'; + END IF; + IF stored.authentication_method NOT IN ('oidc', 'saml') AND + replacement->>'authenticationMethod' IN ('oidc', 'saml') THEN + RAISE EXCEPTION 'session rotation cannot introduce federation provenance' + USING ERRCODE = 'A0003'; + END IF; + IF stored.absolute_expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ OR + stored.idle_expires_at <= (payload->>'rotatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'session expired' USING ERRCODE = 'A0010'; + END IF; + IF replacement->>'userId' <> stored.user_id OR + replacement->>'familyId' <> stored.family_id OR + replacement->>'rotatedFromId' <> stored.id OR + (replacement->>'rotationCounter')::BIGINT <> + (stored.document->>'rotationCounter')::BIGINT + 1 OR + (replacement->>'createdAt')::TIMESTAMPTZ <> + (payload->>'rotatedAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'session rotation is invalid' USING ERRCODE = 'A0003'; + END IF; + previous := stored.document || jsonb_build_object( + 'state', 'rotated', 'rotatedToId', replacement->'id', + 'rotatedAt', to_jsonb((payload->>'rotatedAt')::TIMESTAMPTZ), + 'version', stored.version + 1 + ); + UPDATE aether_identity.sessions + SET state = 'rotated', version = stored.version + 1, document = previous + WHERE id = stored.id; + PERFORM aether_identity.insert_session(replacement); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'rotate_session', + jsonb_build_object('previous', previous, 'replacement', replacement, 'auditEvent', audit_event) + ); +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_link_external_identity(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + payload JSONB; + identity_document JSONB; + receipt JSONB; + lease JSONB; + audit_event JSONB; + jit_provisioning JSONB; + provisioned_user JSONB := 'null'::JSONB; + provisioned_membership JSONB := 'null'::JSONB; + linked_user aether_identity.users%ROWTYPE; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'link_external_identity'); + identity_document := payload->'identity'; + receipt := payload->'replayReceipt'; + lease := payload->'federationProviderLease'; + audit_event := payload->'auditEvent'; + jit_provisioning := COALESCE(payload->'jitProvisioning', 'null'::JSONB); + IF jit_provisioning = 'null'::JSONB THEN + SELECT * INTO linked_user + FROM aether_identity.users + WHERE id = identity_document->>'userId' + FOR KEY SHARE; + IF NOT FOUND THEN + RAISE EXCEPTION 'external identity user not found' USING ERRCODE = 'A0012'; + END IF; + END IF; + PERFORM aether_identity_internal.require_federation_provider_lease(lease); + IF identity_document->>'state' <> 'active' OR + (identity_document->>'version')::BIGINT <> 0 OR + identity_document->>'provider' IS DISTINCT FROM receipt->>'provider' OR + identity_document->>'provider' IS DISTINCT FROM lease->>'storageKey' OR + audit_event->>'organizationId' IS DISTINCT FROM lease->>'organizationId' OR + audit_event->>'action' IS DISTINCT FROM 'external_identity.linked' OR + (receipt->>'expiresAt')::TIMESTAMPTZ <= (audit_event->>'occurredAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'external identity link is invalid' USING ERRCODE = 'A0003'; + END IF; + + IF jit_provisioning <> 'null'::JSONB THEN + IF jsonb_typeof(jit_provisioning) IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'federation JIT provisioning is invalid' USING ERRCODE = 'A0003'; + END IF; + provisioned_user := jit_provisioning->'user'; + provisioned_membership := jit_provisioning->'membership'; + IF jsonb_typeof(provisioned_user) IS DISTINCT FROM 'object' OR + jsonb_typeof(provisioned_membership) IS DISTINCT FROM 'object' OR + provisioned_user->>'state' IS DISTINCT FROM 'active' OR + (provisioned_user->>'version')::BIGINT IS DISTINCT FROM 0 OR + provisioned_user->>'primaryEmail' IS NOT NULL OR + provisioned_membership->>'userId' IS DISTINCT FROM provisioned_user->>'id' OR + provisioned_membership->>'organizationId' IS DISTINCT FROM lease->>'organizationId' OR + provisioned_membership->>'role' IS DISTINCT FROM 'viewer' OR + provisioned_membership->>'state' IS DISTINCT FROM 'active' OR + (provisioned_membership->>'version')::BIGINT IS DISTINCT FROM 0 OR + identity_document->>'userId' IS DISTINCT FROM provisioned_user->>'id' OR + (identity_document->>'createdAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ OR + (identity_document->>'updatedAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ OR + (provisioned_user->>'createdAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ OR + (provisioned_user->>'updatedAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ OR + (provisioned_user->>'activatedAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ OR + (provisioned_membership->>'createdAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ OR + (provisioned_membership->>'updatedAt')::TIMESTAMPTZ IS DISTINCT FROM + (audit_event->>'occurredAt')::TIMESTAMPTZ THEN + RAISE EXCEPTION 'federation JIT provisioning is invalid' USING ERRCODE = 'A0003'; + END IF; + END IF; + + PERFORM aether_identity.insert_external_replay(receipt); + IF jit_provisioning <> 'null'::JSONB THEN + PERFORM aether_identity.insert_user(provisioned_user); + PERFORM aether_identity.insert_membership(provisioned_membership); + END IF; + INSERT INTO aether_identity.external_identities( + id, user_id, provider, subject, state, version, document + ) VALUES ( + identity_document->>'id', identity_document->>'userId', identity_document->>'provider', + identity_document->>'subject', identity_document->>'state', + (identity_document->>'version')::BIGINT, identity_document + ); + PERFORM aether_identity.record_audit(audit_event); + RETURN aether_identity.rpc_success( + 'link_external_identity', + jsonb_build_object( + 'identity', identity_document, + 'replayReceipt', receipt, + 'auditEvent', audit_event, + 'provisionedUser', provisioned_user, + 'provisionedMembership', provisioned_membership + ) + ); +EXCEPTION + WHEN unique_violation THEN + IF EXISTS ( + SELECT 1 FROM aether_identity.external_replay_receipts + WHERE id = receipt->>'id' OR ( + provider = receipt->>'provider' AND + assertion_digest_algorithm = receipt#>>'{assertionDigest,algorithm}' AND + assertion_digest_encoded = receipt#>>'{assertionDigest,encoded}' AND + COALESCE(assertion_digest_key_version, '') = + COALESCE(receipt#>>'{assertionDigest,keyVersion}', '') + ) + ) THEN + RAISE EXCEPTION 'external assertion replayed' USING ERRCODE = 'A0005'; + END IF; + RAISE; +END; +$$; + +CREATE OR REPLACE FUNCTION aether_identity.v1_record_external_identity_replay(p_request JSONB) +RETURNS JSONB +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE payload JSONB; receipt JSONB; lease JSONB; +BEGIN + payload := aether_identity.rpc_payload(p_request, 'record_external_identity_replay'); + receipt := payload->'replayReceipt'; + lease := payload->'federationProviderLease'; + PERFORM aether_identity_internal.require_federation_provider_lease(lease); + IF receipt->>'provider' IS DISTINCT FROM lease->>'storageKey' THEN + RAISE EXCEPTION 'external replay provider is invalid' USING ERRCODE = 'A0003'; + END IF; + PERFORM aether_identity.insert_external_replay(receipt); + RETURN aether_identity.rpc_success('record_external_identity_replay', receipt); +EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'external assertion replayed' USING ERRCODE = 'A0005'; +END; +$$; + +-- A provider disable discovered while a passkey step-up rotates a federated predecessor is a +-- deterministic ceremony rejection. Extend V010's terminal-attempt resolver without modifying +-- the reviewed earlier migration. +CREATE OR REPLACE FUNCTION aether_identity_internal.web_authn_store_error_code(p_sqlstate TEXT) +RETURNS TEXT +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT CASE p_sqlstate + WHEN 'A0002' THEN 'version_conflict' + WHEN 'A0003' THEN 'invalid_transition' + WHEN 'A0004' THEN 'last_owner' + WHEN 'A0005' THEN 'replay_detected' + WHEN 'A0006' THEN 'idempotency_conflict' + WHEN 'A0009' THEN 'session_not_active' + WHEN 'A0010' THEN 'session_expired' + WHEN 'A0011' THEN 'recovery_code_not_active' + WHEN 'A0012' THEN 'not_found' + WHEN 'A0013' THEN 'already_exists' + WHEN 'A0015' THEN 'federation_provider_disabled' + WHEN '23505' THEN 'unique_constraint' + ELSE 'invalid_transition' + END; +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.web_authn_store_error_code(TEXT) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION aether_identity_internal.resolve_web_authn_attempt( + p_request JSONB, + p_operation TEXT +) RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + payload JSONB; + attempted_at TIMESTAMPTZ; + rejection_audit JSONB; + stored_challenge aether_identity.challenges%ROWTYPE; + completion_response JSONB; + failed_challenge JSONB; + rejected_sqlstate TEXT; +BEGIN + IF p_operation NOT IN ( + 'complete_credential_registration', + 'complete_credential_authentication', + 'quarantine_credential_authentication', + 'complete_recovery_enrollment' + ) THEN + RAISE EXCEPTION 'invalid WebAuthn completion operation' USING ERRCODE = 'A0014'; + END IF; + + payload := aether_identity.rpc_payload(p_request, p_operation); + attempted_at := CASE p_operation + WHEN 'complete_credential_registration' THEN + (payload#>>'{auditEvent,occurredAt}')::TIMESTAMPTZ + WHEN 'complete_credential_authentication' THEN + (payload->>'authenticatedAt')::TIMESTAMPTZ + WHEN 'quarantine_credential_authentication' THEN + (payload->>'detectedAt')::TIMESTAMPTZ + WHEN 'complete_recovery_enrollment' THEN + (payload->>'completedAt')::TIMESTAMPTZ + END; + rejection_audit := payload->'rejectionAuditEvent'; + + SELECT * INTO stored_challenge + FROM aether_identity.challenges + WHERE id = payload->>'challengeId' + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'WebAuthn challenge not found' USING ERRCODE = 'A0012'; + END IF; + IF stored_challenge.version <> (payload->>'expectedChallengeVersion')::BIGINT THEN + RAISE EXCEPTION 'WebAuthn challenge version conflict' USING ERRCODE = 'A0002'; + END IF; + IF stored_challenge.state <> 'pending' THEN + RAISE EXCEPTION 'WebAuthn challenge is not pending' USING ERRCODE = 'A0007'; + END IF; + IF stored_challenge.expires_at <= attempted_at THEN + RAISE EXCEPTION 'WebAuthn challenge expired' USING ERRCODE = 'A0008'; + END IF; + IF attempted_at IS NULL OR jsonb_typeof(rejection_audit) IS DISTINCT FROM 'object' OR + rejection_audit->>'action' IS DISTINCT FROM 'webauthn.ceremony_rejected' OR + rejection_audit->>'outcome' IS DISTINCT FROM 'denied' OR + rejection_audit#>>'{target,type}' IS DISTINCT FROM 'challenge' OR + rejection_audit#>>'{target,id}' IS DISTINCT FROM stored_challenge.id OR + rejection_audit->>'reasonCode' IS DISTINCT FROM 'webauthn_store_rejected' OR + (rejection_audit->>'occurredAt')::TIMESTAMPTZ IS DISTINCT FROM attempted_at THEN + RAISE EXCEPTION 'invalid WebAuthn rejection audit' USING ERRCODE = 'A0014'; + END IF; + + BEGIN + EXECUTE format( + 'SELECT aether_identity_internal.%I($1)', + 'v1_' || p_operation + ) INTO completion_response USING p_request; + RETURN aether_identity.rpc_success( + p_operation, + jsonb_build_object( + 'completion', completion_response->'result', + 'rejection', 'null'::JSONB + ) + ); + EXCEPTION + WHEN SQLSTATE 'A0002' OR SQLSTATE 'A0003' OR SQLSTATE 'A0004' OR + SQLSTATE 'A0005' OR SQLSTATE 'A0006' OR SQLSTATE 'A0009' OR + SQLSTATE 'A0010' OR SQLSTATE 'A0011' OR SQLSTATE 'A0012' OR + SQLSTATE 'A0013' OR SQLSTATE 'A0015' OR + integrity_constraint_violation OR data_exception THEN + GET STACKED DIAGNOSTICS rejected_sqlstate = RETURNED_SQLSTATE; + END; + + failed_challenge := aether_identity.consume_challenge_model( + stored_challenge.id, + stored_challenge.version, + 'failed', + attempted_at + ); + PERFORM aether_identity.record_audit(rejection_audit); + RETURN aether_identity.rpc_success( + p_operation, + jsonb_build_object( + 'completion', 'null'::JSONB, + 'rejection', jsonb_build_object( + 'challenge', failed_challenge, + 'error', jsonb_build_object( + 'code', aether_identity_internal.web_authn_store_error_code(rejected_sqlstate), + 'retryable', FALSE + ), + 'auditEvent', rejection_audit + ) + ) + ); +END; +$$; +REVOKE ALL ON FUNCTION aether_identity_internal.resolve_web_authn_attempt(JSONB, TEXT) FROM PUBLIC; + +-- V002 exposed this compatibility RPC through PostgreSQL's default PUBLIC function privilege. +-- The storage adapter no longer has a corresponding operation; retain the function for upgrades +-- while making it unreachable to application and PostgREST roles unless explicitly granted. +REVOKE ALL ON FUNCTION aether_identity.v1_revoke_federated_sessions(JSONB) FROM PUBLIC; + +DO $$ +BEGIN + IF has_function_privilege( + 'public', + 'aether_identity_internal.federation_provider_lease(jsonb)', + 'EXECUTE' + ) OR has_function_privilege( + 'public', + 'aether_identity_internal.require_federation_provider_lease(jsonb)', + 'EXECUTE' + ) OR has_function_privilege( + 'public', + 'aether_identity_internal.require_federated_session(jsonb)', + 'EXECUTE' + ) OR has_function_privilege( + 'public', + 'aether_identity.v1_revoke_federated_sessions(jsonb)', + 'EXECUTE' + ) THEN + RAISE EXCEPTION 'obsolete or internal federation functions remain publicly reachable'; + END IF; +END; +$$; diff --git a/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityConfigTest.kt b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityConfigTest.kt new file mode 100644 index 0000000..dc7e318 --- /dev/null +++ b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityConfigTest.kt @@ -0,0 +1,65 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.SecretReference +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class PostgresqlIdentityConfigTest { + @Test + fun acceptsSecureAndExactLoopbackPostgrestUrls() { + PostgresqlIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + postgrestBaseUrl = "https://identity-db.example.test/api/" + ) + PostgresqlIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + postgrestBaseUrl = "http://localhost:3000" + ) + PostgresqlIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + postgrestBaseUrl = "http://[::1]:3000" + ) + } + + @Test + fun rejectsLookalikeLoopbackAndEnvironmentMismatch() { + assertFailsWith { + PostgresqlIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + postgrestBaseUrl = "http://localhost.example.test" + ) + } + assertFailsWith { + PostgresqlIdentityConfig( + environment = IdentityEnvironment.PRODUCTION, + namespace = "aether_test" + ) + } + } + + @Test + fun productionPostgrestRequiresAnEnvironmentBoundSecretAndRedactsIt() { + val reference = SecretReference( + provider = "vault", + name = "postgrest-production-token", + version = "7", + environment = IdentityEnvironment.PRODUCTION + ) + val config = PostgresqlIdentityConfig( + environment = IdentityEnvironment.PRODUCTION, + namespace = "aether_production", + postgrestBaseUrl = "https://identity-db.example.test", + postgrestAuthorizationSecret = reference + ) + + assertContains(config.toString(), "") + assertFalse(config.toString().contains(reference.name)) + } +} diff --git a/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStoreTest.kt b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStoreTest.kt new file mode 100644 index 0000000..1287f87 --- /dev/null +++ b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStoreTest.kt @@ -0,0 +1,364 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.Credential +import codes.yousef.aether.auth.AcquireFederationProviderLeaseCommand +import codes.yousef.aether.auth.AuditEventId +import codes.yousef.aether.auth.FederationProviderControl +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.OrganizationAuditEventPage +import codes.yousef.aether.auth.OrganizationAuditEventPageRequest +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.PurgeAuditEventsCommand +import codes.yousef.aether.auth.PurgeAuditEventsCommit +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.IdentitySession +import codes.yousef.aether.auth.IdentityStoreError +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.StoreResult +import codes.yousef.aether.auth.TouchIdentitySessionCommand +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.testkit.IdentityFixtures +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class PostgresqlIdentityStoreTest { + @Test + fun failsClosedUntilEnvironmentIsVerifiedAndOnlyInitializesOnce() = runTest { + val transport = RecordingTransport { request -> successFor(request) } + val store = PostgresqlIdentityStore(config(), transport) + + val beforeInitialization = assertIs( + store.findUser(IdentityFixtures.userId("user-1")) + ) + assertEquals(IdentityStoreErrorCode.UNAVAILABLE, beforeInitialization.error.code) + assertEquals(0, transport.requests.size) + + assertIs>(store.initialize()) + assertIs>(store.initialize()) + assertEquals( + listOf(PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName), + transport.requests.map { it.operation } + ) + + val missing = assertIs>( + store.findUser(IdentityFixtures.userId("missing")) + ) + assertNull(missing.value) + } + + @Test + fun rejectsMismatchedDatabaseMarker() = runTest { + val transport = RecordingTransport { request -> + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = buildJsonObject { + put("verified", true) + put("environment", "development") + put("namespace", "aether_development") + } + ) + } + val result = PostgresqlIdentityStore(config(), transport).initialize() + + val failure = assertIs(result) + assertEquals(IdentityStoreErrorCode.INTERNAL, failure.error.code) + } + + @Test + fun missingDatabaseMarkerFailureNeverInitializesOrMutatesThroughAnotherOperation() = runTest { + val transport = RecordingTransport { request -> + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.FAILURE, + error = IdentityStoreError(IdentityStoreErrorCode.INTERNAL) + ) + } + val store = PostgresqlIdentityStore(config(), transport) + + assertEquals( + IdentityStoreErrorCode.INTERNAL, + assertIs(store.initialize()).error.code + ) + assertEquals( + IdentityStoreErrorCode.UNAVAILABLE, + assertIs(store.findUser(IdentityFixtures.userId("missing-marker"))).error.code + ) + assertEquals( + listOf(PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName), + transport.requests.map { it.operation } + ) + } + + @Test + fun routesWebAuthnCredentialLookupToItsDedicatedOperation() = runTest { + val fixture = IdentityFixtures.credential() + val json = defaultPostgresqlJson() + val transport = RecordingTransport { request -> + if (request.operation == PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName) { + successFor(request) + } else { + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = json.encodeToJsonElement(Credential.serializer(), fixture) + ) + } + } + val store = PostgresqlIdentityStore(config(), transport, json) + assertIs>(store.initialize()) + + val found = assertIs>(store.findCredentialByWebAuthnId(fixture.webAuthnId)) + assertEquals(fixture, found.value) + val lookup = transport.requests.last() + assertEquals(PostgresqlRpcOperation.FIND_CREDENTIAL_BY_WEB_AUTHN_ID.wireName, lookup.operation) + assertEquals(fixture.webAuthnId.encoded, lookup.payload.jsonObject.getValue("webAuthnId").jsonPrimitive.content) + } + + @Test + fun propagatesOnlySafeFailureEnvelope() = runTest { + val transport = RecordingTransport { request -> + if (request.operation == PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName) { + successFor(request) + } else { + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.FAILURE, + error = IdentityStoreError(IdentityStoreErrorCode.LAST_OWNER) + ) + } + } + val store = PostgresqlIdentityStore(config(), transport) + assertIs>(store.initialize()) + + val result = assertIs(store.findUser(IdentityFixtures.userId("user-1"))) + assertEquals(IdentityStoreErrorCode.LAST_OWNER, result.error.code) + } + + @Test + fun routesBoundedOrganizationAuditPageThroughTheFixedRpc() = runTest { + val organizationId = IdentityFixtures.organizationId("organization-audit") + val event = IdentityFixtures.auditEvent(IdentityFixtures.auditEventId("audit-page")).copy( + organizationId = organizationId + ) + val page = OrganizationAuditEventPage(organizationId, listOf(event)) + val json = defaultPostgresqlJson() + val transport = RecordingTransport { request -> + if (request.operation == PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName) { + successFor(request) + } else { + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = json.encodeToJsonElement(OrganizationAuditEventPage.serializer(), page) + ) + } + } + val store = PostgresqlIdentityStore(config(), transport, json) + assertIs>(store.initialize()) + + assertEquals( + page, + assertIs>( + store.listAuditEventsForOrganization( + OrganizationAuditEventPageRequest(organizationId, limit = 25) + ) + ).value + ) + val request = transport.requests.last() + assertEquals(PostgresqlRpcOperation.LIST_AUDIT_EVENTS_FOR_ORGANIZATION.wireName, request.operation) + assertEquals(organizationId.value, request.payload.jsonObject.getValue("organizationId").jsonPrimitive.content) + assertEquals("25", request.payload.jsonObject.getValue("limit").jsonPrimitive.content) + } + + @Test + fun routesBoundedAuditRetentionThroughTheFixedRpc() = runTest { + val expected = PurgeAuditEventsCommit(deletedCount = 250, hasMore = true) + val json = defaultPostgresqlJson() + val transport = RecordingTransport { request -> + if (request.operation == PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName) { + successFor(request) + } else { + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = json.encodeToJsonElement(PurgeAuditEventsCommit.serializer(), expected) + ) + } + } + val store = PostgresqlIdentityStore(config(), transport, json) + assertIs>(store.initialize()) + val command = PurgeAuditEventsCommand( + occurredBefore = IdentityFixtures.instant(50_000), + maximumEvents = 250 + ) + + assertEquals(expected, assertIs>( + store.purgeAuditEvents(command) + ).value) + val request = transport.requests.last() + assertEquals(PostgresqlRpcOperation.PURGE_AUDIT_EVENTS.wireName, request.operation) + assertEquals("250", request.payload.jsonObject.getValue("maximumEvents").jsonPrimitive.content) + assertEquals( + command.occurredBefore.toString(), + request.payload.jsonObject.getValue("occurredBefore").jsonPrimitive.content + ) + } + + @Test + fun routesAuditFreeIdentitySessionTouchThroughTheFixedRpc() = runTest { + val original = IdentityFixtures.session(id = IdentityFixtures.sessionId("session-touch-rpc")) + val command = TouchIdentitySessionCommand( + sessionId = original.id, + expectedVersion = original.version, + lastUsedAt = IdentityFixtures.instant(60_000), + idleExpiresAt = IdentityFixtures.instant(3_660_000) + ) + val expected = original.copy( + version = 1, + lastUsedAt = command.lastUsedAt, + idleExpiresAt = command.idleExpiresAt + ) + val json = defaultPostgresqlJson() + val transport = RecordingTransport { request -> + if (request.operation == PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName) { + successFor(request) + } else { + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = json.encodeToJsonElement(IdentitySession.serializer(), expected) + ) + } + } + val store = PostgresqlIdentityStore(config(), transport, json) + assertIs>(store.initialize()) + + assertEquals( + expected, + assertIs>(store.touchIdentitySession(command)).value + ) + val request = transport.requests.last() + assertEquals(PostgresqlRpcOperation.TOUCH_IDENTITY_SESSION.wireName, request.operation) + assertEquals( + setOf("sessionId", "expectedVersion", "lastUsedAt", "idleExpiresAt"), + request.payload.jsonObject.keys + ) + assertEquals(original.id.value, request.payload.jsonObject.getValue("sessionId").jsonPrimitive.content) + assertEquals("0", request.payload.jsonObject.getValue("expectedVersion").jsonPrimitive.content) + } + + @Test + fun routesFederationProviderLookupsAndLeasesThroughFixedRpcs() = runTest { + val control = IdentityFixtures.federationProviderControl() + val lease = IdentityFixtures.federationProviderLease(control) + val json = defaultPostgresqlJson() + val transport = RecordingTransport { request -> + when (request.operation) { + PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName -> successFor(request) + PostgresqlRpcOperation.FIND_FEDERATION_PROVIDER_CONTROL.wireName, + PostgresqlRpcOperation.FIND_FEDERATION_PROVIDER_CONTROL_BY_STORAGE_KEY.wireName -> + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = json.encodeToJsonElement(FederationProviderControl.serializer(), control) + ) + else -> PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = json.encodeToJsonElement(FederationProviderLease.serializer(), lease) + ) + } + } + val store = PostgresqlIdentityStore(config(), transport, json) + assertIs>(store.initialize()) + + assertEquals( + control, + assertIs>( + store.findFederationProviderControl(control.organizationId, control.providerId) + ).value + ) + assertEquals( + setOf("organizationId", "providerId"), + transport.requests.last().payload.jsonObject.keys + ) + assertEquals( + control, + assertIs>( + store.findFederationProviderControlByStorageKey(control.storageKey) + ).value + ) + assertEquals( + setOf("storageKey"), + transport.requests.last().payload.jsonObject.keys + ) + + val acquired = assertIs>( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organizationId = control.organizationId, + kind = control.kind, + providerId = control.providerId, + storageKey = control.storageKey, + acquiredAt = IdentityFixtures.instant() + ) + ) + ) + assertEquals(lease, acquired.value) + assertEquals( + PostgresqlRpcOperation.ACQUIRE_FEDERATION_PROVIDER_LEASE.wireName, + transport.requests.last().operation + ) + + assertEquals( + lease, + assertIs>( + store.validateFederationProviderLease(lease) + ).value + ) + assertEquals( + setOf("organizationId", "kind", "providerId", "storageKey", "sessionEpoch", "version"), + transport.requests.last().payload.jsonObject.keys + ) + } + + private fun config(): PostgresqlIdentityConfig = PostgresqlIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "aether_test" + ) + + private fun successFor(request: PostgresqlRpcRequestEnvelope): PostgresqlRpcResponseEnvelope = + PostgresqlRpcResponseEnvelope( + operation = request.operation, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = if (request.operation == PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName) { + buildJsonObject { + put("verified", true) + put("environment", "test") + put("namespace", "aether_test") + } + } else { + JsonNull + } + ) +} + +private class RecordingTransport( + private val responder: (PostgresqlRpcRequestEnvelope) -> PostgresqlRpcResponseEnvelope +) : PostgresqlRpcTransport { + val requests = mutableListOf() + + override suspend fun execute(request: PostgresqlRpcRequestEnvelope): PostgresqlRpcResponseEnvelope { + requests += request + return responder(request) + } +} diff --git a/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcProtocolTest.kt b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcProtocolTest.kt new file mode 100644 index 0000000..e499cf9 --- /dev/null +++ b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlRpcProtocolTest.kt @@ -0,0 +1,64 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityStoreErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PostgresqlRpcProtocolTest { + @Test + fun everyOperationUsesAUniqueFixedVersionedFunction() { + val operations = PostgresqlRpcOperation.entries + + assertEquals(operations.size, operations.map { it.wireName }.toSet().size) + assertEquals(operations.size, operations.map { it.functionName }.toSet().size) + assertTrue(operations.all { it.functionName == "v1_${it.wireName}" }) + assertTrue(PostgresqlRpcOperation.FIND_CREDENTIAL_BY_WEB_AUTHN_ID in operations) + assertTrue(PostgresqlRpcOperation.BOOTSTRAP_IDENTITY in operations) + assertTrue(PostgresqlRpcOperation.ENROLL_INVITATION in operations) + assertTrue(PostgresqlRpcOperation.LIST_AUDIT_EVENTS_FOR_ORGANIZATION in operations) + assertTrue(PostgresqlRpcOperation.PURGE_AUDIT_EVENTS in operations) + assertTrue(PostgresqlRpcOperation.TOUCH_IDENTITY_SESSION in operations) + assertTrue(PostgresqlRpcOperation.FIND_FEDERATION_PROVIDER_CONTROL in operations) + assertTrue(PostgresqlRpcOperation.FIND_FEDERATION_PROVIDER_CONTROL_BY_STORAGE_KEY in operations) + assertTrue(PostgresqlRpcOperation.ACQUIRE_FEDERATION_PROVIDER_LEASE in operations) + assertTrue(PostgresqlRpcOperation.VALIDATE_FEDERATION_PROVIDER_LEASE in operations) + assertTrue(PostgresqlRpcOperation.COMPARE_AND_SET_FEDERATION_PROVIDER_STATE in operations) + assertTrue(PostgresqlRpcOperation.ACTIVATE_ADMINISTRATIVE_RECOVERY_TICKET in operations) + assertTrue(PostgresqlRpcOperation.FIND_SCIM_GROUP in operations) + assertTrue(PostgresqlRpcOperation.APPLY_SCIM_BATCH in operations) + } + + @Test + fun providerFailuresMapToStableSafeStoreErrors() { + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + PostgresqlFailureMapper.fromProviderCode("23505").code + ) + assertEquals( + IdentityStoreErrorCode.REPLAY_DETECTED, + PostgresqlFailureMapper.fromProviderCode("A0005").code + ) + assertEquals( + IdentityStoreErrorCode.LAST_OWNER, + PostgresqlFailureMapper.fromProviderCode("A0004").code + ) + assertEquals( + IdentityStoreErrorCode.ALREADY_EXISTS, + PostgresqlFailureMapper.fromProviderCode("A0013").code + ) + val disabledProvider = PostgresqlFailureMapper.fromProviderCode("A0015") + assertEquals(IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED, disabledProvider.code) + assertFalse(disabledProvider.retryable) + val serializationFailure = PostgresqlFailureMapper.fromProviderCode("40001") + assertEquals(IdentityStoreErrorCode.VERSION_CONFLICT, serializationFailure.code) + assertTrue(serializationFailure.retryable) + + val unknown = PostgresqlStoreException( + PostgresqlFailureMapper.fromProviderCode("raw-secret-provider-code") + ) + assertEquals(IdentityStoreErrorCode.INTERNAL, unknown.safeError.code) + assertFalse(unknown.toString().contains("raw-secret-provider-code")) + } +} diff --git a/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgrestPostgresqlRpcTransportTest.kt b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgrestPostgresqlRpcTransportTest.kt new file mode 100644 index 0000000..cc6f515 --- /dev/null +++ b/aether-auth-postgresql/src/commonTest/kotlin/codes/yousef/aether/auth/postgresql/PostgrestPostgresqlRpcTransportTest.kt @@ -0,0 +1,153 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.IdentityHttpResponse +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.SecretReference +import codes.yousef.aether.auth.testkit.DeterministicIdentityHttpClient +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.DeterministicIdentitySecretResolver +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PostgrestPostgresqlRpcTransportTest { + private val json = defaultPostgresqlJson() + private val authorizationReference = SecretReference( + provider = "test", + name = "postgrest-token", + version = "1", + environment = IdentityEnvironment.TEST + ) + + @Test + fun sendsStablePostgrestEnvelopeAndProfileHeaders() = runTest { + val responseEnvelope = PostgresqlRpcResponseEnvelope( + operation = PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = buildJsonObject { + put("verified", true) + put("environment", "test") + put("namespace", "aether_test") + } + ) + val http = DeterministicIdentityHttpClient( + listOf(IdentityHttpResponse(200, body = json.encodeToString(responseEnvelope).encodeToByteArray())) + ) + val secrets = DeterministicIdentitySecretResolver( + mapOf(authorizationReference to "header.token-value".encodeToByteArray()) + ) + val runtime = DeterministicIdentityRuntime( + deterministicHttp = http, + deterministicSecrets = secrets + ) + val transport = PostgrestPostgresqlRpcTransport(config(), runtime.runtime, json) + val requestEnvelope = request(PostgresqlRpcOperation.ASSERT_ENVIRONMENT) + + assertEquals(responseEnvelope, transport.execute(requestEnvelope)) + val recorded = http.recordedRequests().single() + assertEquals("http://localhost:3000/rest/v1/rpc/v1_assert_environment", recorded.url) + assertEquals("aether_identity", recorded.headers["Accept-Profile"]) + assertEquals("aether_identity", recorded.headers["Content-Profile"]) + assertEquals("Bearer header.token-value", recorded.headers["Authorization"]) + + val document = json.parseToJsonElement(recorded.bodyBytes().decodeToString()).jsonObject + val pRequest = document.getValue("p_request").jsonObject + assertEquals(1, pRequest.getValue("protocolVersion").jsonPrimitive.content.toInt()) + assertEquals("assert_environment", pRequest.getValue("operation").jsonPrimitive.content) + assertEquals("test", pRequest.getValue("environment").jsonPrimitive.content) + assertEquals("aether_test", pRequest.getValue("namespace").jsonPrimitive.content) + } + + @Test + fun mapsPostgrestFailureWithoutRetainingProviderText() = runTest { + val providerBody = """{"code":"23505","message":"credential-secret-leaked"}""" + val http = DeterministicIdentityHttpClient( + listOf(IdentityHttpResponse(409, body = providerBody.encodeToByteArray())) + ) + val runtime = DeterministicIdentityRuntime(deterministicHttp = http) + val transport = PostgrestPostgresqlRpcTransport(config(withAuthorization = false), runtime.runtime, json) + + val failure = assertFailsWith { + transport.execute(request(PostgresqlRpcOperation.FIND_USER)) + } + assertEquals(IdentityStoreErrorCode.UNIQUE_CONSTRAINT, failure.safeError.code) + assertFalse(failure.toString().contains("credential-secret-leaked")) + assertFalse(failure.message.orEmpty().contains("credential-secret-leaked")) + } + + @Test + fun tenantAuditReadUsesItsFixedPostgrestFunction() = runTest { + val operation = PostgresqlRpcOperation.LIST_AUDIT_EVENTS_FOR_ORGANIZATION + val responseEnvelope = PostgresqlRpcResponseEnvelope( + operation = operation.wireName, + outcome = PostgresqlRpcOutcome.SUCCESS, + result = buildJsonObject { + put("organizationId", "organization-audit") + put("events", kotlinx.serialization.json.buildJsonArray { }) + put("nextCursor", kotlinx.serialization.json.JsonNull) + } + ) + val http = DeterministicIdentityHttpClient( + listOf(IdentityHttpResponse(200, body = json.encodeToString(responseEnvelope).encodeToByteArray())) + ) + val runtime = DeterministicIdentityRuntime(deterministicHttp = http) + val transport = PostgrestPostgresqlRpcTransport(config(withAuthorization = false), runtime.runtime, json) + + assertEquals(responseEnvelope, transport.execute(request(operation))) + val recorded = http.recordedRequests().single() + assertEquals( + "http://localhost:3000/rest/v1/rpc/v1_list_audit_events_for_organization", + recorded.url + ) + assertEquals( + operation.wireName, + json.parseToJsonElement(recorded.bodyBytes().decodeToString()).jsonObject + .getValue("p_request").jsonObject.getValue("operation").jsonPrimitive.content + ) + } + + @Test + fun rejectsHeaderInjectionBeforeCallingHttp() = runTest { + val http = DeterministicIdentityHttpClient() + val secrets = DeterministicIdentitySecretResolver( + mapOf(authorizationReference to "valid\r\nInjected: true".encodeToByteArray()) + ) + val runtime = DeterministicIdentityRuntime( + deterministicHttp = http, + deterministicSecrets = secrets + ) + val transport = PostgrestPostgresqlRpcTransport(config(), runtime.runtime, json) + + val failure = assertFailsWith { + transport.execute(request(PostgresqlRpcOperation.FIND_USER)) + } + assertEquals(IdentityStoreErrorCode.INTERNAL, failure.safeError.code) + assertTrue(http.recordedRequests().isEmpty()) + } + + private fun config(withAuthorization: Boolean = true): PostgresqlIdentityConfig = PostgresqlIdentityConfig( + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + postgrestBaseUrl = "http://localhost:3000/rest/v1/", + postgrestAuthorizationSecret = authorizationReference.takeIf { withAuthorization } + ) + + private fun request(operation: PostgresqlRpcOperation): PostgresqlRpcRequestEnvelope = + PostgresqlRpcRequestEnvelope( + operation = operation.wireName, + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + requestId = "request-1", + payload = buildJsonObject { put("id", JsonPrimitive("subject-1")) } + ) +} diff --git a/aether-auth-postgresql/src/jvmMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlMigrationRunner.kt b/aether-auth-postgresql/src/jvmMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlMigrationRunner.kt new file mode 100644 index 0000000..7018987 --- /dev/null +++ b/aether-auth-postgresql/src/jvmMain/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlMigrationRunner.kt @@ -0,0 +1,287 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.StoreResult +import io.vertx.kotlin.coroutines.coAwait +import io.vertx.pgclient.PgException +import io.vertx.sqlclient.Pool +import io.vertx.sqlclient.Tuple +import java.net.JarURLConnection +import java.nio.file.Files +import java.security.MessageDigest +import kotlinx.coroutines.CancellationException + +data class PostgresqlMigrationReport( + val version: Int, + val applied: Boolean, + val checksum: String +) + +/** Applies the reviewed identity migrations only after the complete packaged bundle matches its manifest. */ +class PostgresqlMigrationRunner( + private val pool: Pool, + private val migrationResources: List = DEFAULT_MIGRATION_RESOURCES +) { + constructor(pool: Pool, migrationResource: String) : this(pool, listOf(migrationResource)) + + suspend fun migrate(): StoreResult { + val migrations = loadMigrations() + ?: return StoreResult.Failure(PostgresqlFailureMapper.internal()) + val connection = try { + pool.connection.coAwait() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + return StoreResult.Failure(PostgresqlFailureMapper.unavailable()) + } + + val transaction = try { + connection.begin().coAwait() + } catch (cancelled: CancellationException) { + connection.close().coAwait() + throw cancelled + } catch (_: Throwable) { + connection.close().coAwait() + return StoreResult.Failure(PostgresqlFailureMapper.unavailable()) + } + + return try { + connection.query(BOOTSTRAP_SQL).execute().coAwait() + connection.preparedQuery("SELECT pg_advisory_xact_lock(hashtext(\$1))") + .execute(Tuple.of(MODULE_NAME)).coAwait() + var applied = false + for (migration in migrations) { + val rows = connection.preparedQuery( + "SELECT checksum FROM aether_identity.schema_migrations " + + "WHERE module = \$1 AND version = \$2 FOR UPDATE" + ).execute(Tuple.of(MODULE_NAME, migration.version)).coAwait() + val iterator = rows.iterator() + val existingChecksum = if (iterator.hasNext()) iterator.next().getString("checksum") else null + when { + existingChecksum == null -> { + connection.query(migration.sql).execute().coAwait() + connection.preparedQuery( + "INSERT INTO aether_identity.schema_migrations(module, version, checksum) " + + "VALUES (\$1, \$2, \$3)" + ).execute(Tuple.of(MODULE_NAME, migration.version, migration.checksum)).coAwait() + applied = true + } + existingChecksum != migration.checksum -> { + transaction.rollback().coAwait() + return StoreResult.Failure(PostgresqlFailureMapper.internal()) + } + } + } + transaction.commit().coAwait() + val latest = migrations.last() + StoreResult.Success(PostgresqlMigrationReport(latest.version, applied, latest.checksum)) + } catch (cancelled: CancellationException) { + rollbackQuietly(transaction) + throw cancelled + } catch (failure: PgException) { + rollbackQuietly(transaction) + StoreResult.Failure(PostgresqlFailureMapper.fromProviderCode(failure.sqlState)) + } catch (_: Throwable) { + rollbackQuietly(transaction) + StoreResult.Failure(PostgresqlFailureMapper.internal()) + } finally { + runCatching { connection.close().coAwait() } + } + } + + private fun loadMigrations(): List? = loadPackagedPostgresqlMigrations( + requestedResources = migrationResources + ) + + private suspend fun rollbackQuietly(transaction: io.vertx.sqlclient.Transaction) { + runCatching { transaction.rollback().coAwait() } + } + + companion object { + const val DEFAULT_MIGRATION_RESOURCE: String = "/db/aether-identity/V001__identity_foundation.sql" + const val FEDERATED_SESSION_MIGRATION_RESOURCE: String = + "/db/aether-identity/V002__federated_session_provenance.sql" + const val ORGANIZATION_AUDIT_READ_MIGRATION_RESOURCE: String = + "/db/aether-identity/V003__organization_audit_reads.sql" + const val AUDIT_RETENTION_MIGRATION_RESOURCE: String = + "/db/aether-identity/V004__audit_retention.sql" + const val DEVICE_GRANT_CAS_MIGRATION_RESOURCE: String = + "/db/aether-identity/V005__device_grant_cas_serialization.sql" + const val IDENTITY_SESSION_TOUCH_MIGRATION_RESOURCE: String = + "/db/aether-identity/V006__identity_session_touch.sql" + const val ADMINISTRATIVE_RECOVERY_ACTIVATION_MIGRATION_RESOURCE: String = + "/db/aether-identity/V007__administrative_recovery_activation.sql" + const val DEVICE_MEMBERSHIP_BINDING_MIGRATION_RESOURCE: String = + "/db/aether-identity/V008__device_membership_binding.sql" + const val FAIL_CLOSED_ENVIRONMENT_MARKER_MIGRATION_RESOURCE: String = + "/db/aether-identity/V009__fail_closed_environment_marker.sql" + const val TERMINAL_WEBAUTHN_ATTEMPTS_MIGRATION_RESOURCE: String = + "/db/aether-identity/V010__terminal_webauthn_attempts.sql" + const val FEDERATION_PROVIDER_LIFECYCLE_MIGRATION_RESOURCE: String = + "/db/aether-identity/V011__federation_provider_lifecycle.sql" + const val MIGRATION_CHECKSUM_MANIFEST_RESOURCE: String = + "/db/aether-identity/SHA256SUMS" + val DEFAULT_MIGRATION_RESOURCES: List = listOf( + DEFAULT_MIGRATION_RESOURCE, + FEDERATED_SESSION_MIGRATION_RESOURCE, + ORGANIZATION_AUDIT_READ_MIGRATION_RESOURCE, + AUDIT_RETENTION_MIGRATION_RESOURCE, + DEVICE_GRANT_CAS_MIGRATION_RESOURCE, + IDENTITY_SESSION_TOUCH_MIGRATION_RESOURCE, + ADMINISTRATIVE_RECOVERY_ACTIVATION_MIGRATION_RESOURCE, + DEVICE_MEMBERSHIP_BINDING_MIGRATION_RESOURCE, + FAIL_CLOSED_ENVIRONMENT_MARKER_MIGRATION_RESOURCE, + TERMINAL_WEBAUTHN_ATTEMPTS_MIGRATION_RESOURCE, + FEDERATION_PROVIDER_LIFECYCLE_MIGRATION_RESOURCE + ) + private const val MODULE_NAME: String = "aether-auth-postgresql" + private val BOOTSTRAP_SQL = """ + CREATE SCHEMA IF NOT EXISTS aether_identity; + CREATE TABLE IF NOT EXISTS aether_identity.schema_migrations ( + module TEXT NOT NULL, + version INTEGER NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (module, version) + ); + """.trimIndent() + } +} + +internal fun loadPackagedPostgresqlMigrations( + requestedResources: List = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES +): List? = loadPostgresqlMigrationBundle( + requestedResources = requestedResources, + manifestBytes = readPackagedResource(PostgresqlMigrationRunner.MIGRATION_CHECKSUM_MANIFEST_RESOURCE), + packagedMigrationResources = discoverPackagedMigrationResources(), + readResource = ::readPackagedResource +) + +internal data class PostgresqlMigration( + val version: Int, + val sql: String, + val checksum: String +) + +internal fun loadPostgresqlMigrationBundle( + requestedResources: List, + manifestBytes: ByteArray?, + packagedMigrationResources: Set?, + readResource: (String) -> ByteArray? +): List? { + if (manifestBytes == null || packagedMigrationResources == null) return null + if (requestedResources.isEmpty() || requestedResources.toSet().size != requestedResources.size) return null + + val manifest = parseMigrationChecksumManifest(manifestBytes) ?: return null + val reviewedResources = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES + if (manifest.map(MigrationChecksumEntry::resource) != reviewedResources) return null + if (packagedMigrationResources != reviewedResources.toSet()) return null + if (requestedResources.any { it !in reviewedResources }) return null + + val reviewedMigrations = try { + manifest.associate { entry -> + val bytes = readResource(entry.resource) ?: return null + if (bytes.sha256() != entry.checksum) return null + val sql = bytes.decodeToString(throwOnInvalidSequence = true) + val version = entry.resource.migrationVersion() ?: return null + entry.resource to PostgresqlMigration(version, sql, entry.checksum) + } + } catch (_: Exception) { + return null + } + if (reviewedMigrations.values.map(PostgresqlMigration::version).toSet().size != reviewedMigrations.size) { + return null + } + return requestedResources.map { reviewedMigrations.getValue(it) }.sortedBy(PostgresqlMigration::version) +} + +private data class MigrationChecksumEntry( + val resource: String, + val checksum: String +) + +private fun parseMigrationChecksumManifest(bytes: ByteArray): List? { + val text = try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: Exception) { + return null + } + val rawLines = text.split('\n') + val lines = if (rawLines.lastOrNull().isNullOrEmpty()) rawLines.dropLast(1) else rawLines + if (lines.isEmpty() || lines.any(String::isEmpty)) return null + + val entries = lines.map { line -> + val match = MIGRATION_CHECKSUM_LINE.matchEntire(line) ?: return null + MigrationChecksumEntry( + resource = "$MIGRATION_RESOURCE_DIRECTORY/${match.groupValues[2]}", + checksum = match.groupValues[1] + ) + } + if (entries.map(MigrationChecksumEntry::resource).toSet().size != entries.size) return null + return entries +} + +private fun readPackagedResource(resource: String): ByteArray? = try { + PostgresqlMigrationRunner::class.java.getResourceAsStream(resource)?.use { it.readBytes() } +} catch (_: Exception) { + null +} + +/** + * Inventories SQL resources in the same classpath container as the committed manifest. An + * unsupported container protocol is rejected instead of silently skipping the extra-file check. + */ +internal fun discoverPackagedMigrationResources(): Set? { + return try { + val manifestUrl = PostgresqlMigrationRunner::class.java.getResource( + PostgresqlMigrationRunner.MIGRATION_CHECKSUM_MANIFEST_RESOURCE + ) ?: return null + when (manifestUrl.protocol) { + "file" -> { + val directory = java.nio.file.Paths.get(manifestUrl.toURI()).parent + Files.walk(directory).use { paths -> + paths + .filter { Files.isRegularFile(it) && it.fileName.toString().endsWith(".sql") } + .map { path -> + val relative = directory.relativize(path).toString().replace(java.io.File.separatorChar, '/') + "$MIGRATION_RESOURCE_DIRECTORY/$relative" + } + .toList() + .toSet() + } + } + "jar" -> { + val connection = manifestUrl.openConnection() as? JarURLConnection ?: return null + connection.useCaches = false + connection.jarFile.use { jar -> + jar.entries().asSequence() + .filter { entry -> + !entry.isDirectory && + entry.name.startsWith(MIGRATION_RESOURCE_DIRECTORY.removePrefix("/") + "/") && + entry.name.endsWith(".sql") + } + .map { "/${it.name}" } + .toSet() + } + } + else -> null + } + } catch (_: Exception) { + null + } +} + +private fun String.migrationVersion(): Int? { + val fileName = substringAfterLast('/') + val version = fileName.substringAfter('V', missingDelimiterValue = "") + .substringBefore("__", missingDelimiterValue = "") + .toIntOrNull() + return version +} + +private fun ByteArray.sha256(): String = + MessageDigest.getInstance("SHA-256") + .digest(this) + .joinToString(separator = "") { byte -> (byte.toInt() and 0xff).toString(16).padStart(2, '0') } + +private const val MIGRATION_RESOURCE_DIRECTORY: String = "/db/aether-identity" +private val MIGRATION_CHECKSUM_LINE = Regex("^([0-9a-f]{64}) (V[0-9]{3}__[A-Za-z0-9_.-]+\\.sql)$") diff --git a/aether-auth-postgresql/src/jvmMain/kotlin/codes/yousef/aether/auth/postgresql/VertxPostgresqlRpcTransport.kt b/aether-auth-postgresql/src/jvmMain/kotlin/codes/yousef/aether/auth/postgresql/VertxPostgresqlRpcTransport.kt new file mode 100644 index 0000000..c8fc3cb --- /dev/null +++ b/aether-auth-postgresql/src/jvmMain/kotlin/codes/yousef/aether/auth/postgresql/VertxPostgresqlRpcTransport.kt @@ -0,0 +1,59 @@ +package codes.yousef.aether.auth.postgresql + +import io.vertx.pgclient.PgException +import io.vertx.core.json.JsonObject as VertxJsonObject +import io.vertx.sqlclient.SqlClient +import io.vertx.sqlclient.Tuple +import io.vertx.kotlin.coroutines.coAwait +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** JVM transport that invokes the same versioned functions directly through Vert.x SQL client. */ +class VertxPostgresqlRpcTransport( + private val config: PostgresqlIdentityConfig, + private val client: SqlClient, + private val json: Json = defaultPostgresqlJson() +) : PostgresqlRpcTransport { + override suspend fun execute(request: PostgresqlRpcRequestEnvelope): PostgresqlRpcResponseEnvelope { + val operation = operationFor(request) + val encoded = json.encodeToString(request) + if (encoded.encodeToByteArray().size > config.maximumRequestBytes) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + + val responseText = try { + val rows = client.preparedQuery( + "SELECT ${config.schema}.${operation.functionName}(\$1::jsonb)::text AS response" + ).execute(Tuple.of(VertxJsonObject(encoded))).coAwait() + val iterator = rows.iterator() + if (!iterator.hasNext()) throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + iterator.next().getString("response") + ?: throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: PostgresqlStoreException) { + throw failure + } catch (failure: PgException) { + throw PostgresqlStoreException( + PostgresqlFailureMapper.fromProviderCode(failure.sqlState) + ) + } catch (_: Throwable) { + throw PostgresqlStoreException(PostgresqlFailureMapper.unavailable()) + } + + if (responseText.encodeToByteArray().size > config.maximumResponseBytes) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + val decoded = try { + json.decodeFromString(responseText) + } catch (_: SerializationException) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } catch (_: IllegalArgumentException) { + throw PostgresqlStoreException(PostgresqlFailureMapper.internal()) + } + validateResponse(request, decoded) + return decoded + } +} diff --git a/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlAuditRetentionIntegrationTest.kt b/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlAuditRetentionIntegrationTest.kt new file mode 100644 index 0000000..a5add2d --- /dev/null +++ b/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlAuditRetentionIntegrationTest.kt @@ -0,0 +1,100 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.* +import io.vertx.core.Vertx +import io.vertx.kotlin.coroutines.coAwait +import io.vertx.pgclient.PgBuilder +import io.vertx.pgclient.PgConnectOptions +import io.vertx.sqlclient.PoolOptions +import io.vertx.sqlclient.Tuple +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.testcontainers.containers.PostgreSQLContainer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Instant + +class PostgresqlAuditRetentionIntegrationTest { + @Test + fun migrationAndRpcDeleteOnlyTheBoundedStrictlyExpiredBatch() = runBlocking { + val postgres = PostgreSQLContainer("postgres:16-alpine").apply { + withDatabaseName("aether_identity_retention_test") + withUsername("aether") + withPassword("aether-test-password") + } + withContext(Dispatchers.IO) { postgres.start() } + val vertx = Vertx.vertx() + val pool = PgBuilder.pool() + .with(PoolOptions().setMaxSize(4)) + .connectingTo( + PgConnectOptions() + .setHost(postgres.host) + .setPort(postgres.firstMappedPort) + .setDatabase(postgres.databaseName) + .setUser(postgres.username) + .setPassword(postgres.password) + ) + .using(vertx) + .build() + + try { + val migration = assertIs>( + PostgresqlMigrationRunner(pool).migrate() + ).value + assertEquals(11, migration.version) + val config = PostgresqlIdentityConfig(IdentityEnvironment.TEST, "aether_test") + pool.preparedQuery( + "SELECT aether_identity.provision_environment(\$1, \$2)" + ).execute(Tuple.of(config.environment.wireName, config.namespace)).coAwait() + val store = PostgresqlIdentityStore(config, VertxPostgresqlRpcTransport(config, pool)) + assertIs>(store.initialize()) + val cutoff = Instant.fromEpochMilliseconds(1_735_689_603_000) + suspend fun append(id: String, milliseconds: Long) { + assertIs>( + store.appendAuditEvent( + AuditEvent( + id = AuditEventId.parse(id), + actor = AuditActor(AuditActorType.SYSTEM), + action = AuditAction.USER_STATE_CHANGED, + outcome = AuditOutcome.SUCCEEDED, + occurredAt = Instant.fromEpochMilliseconds(milliseconds) + ) + ) + ) + } + append("018f0f2e-7b00-7000-8000-000000000021", cutoff.toEpochMilliseconds() - 2_000) + append("018f0f2e-7b00-7000-8000-000000000022", cutoff.toEpochMilliseconds() - 1_000) + append("018f0f2e-7b00-7000-8000-000000000023", cutoff.toEpochMilliseconds()) + + assertEquals( + PurgeAuditEventsCommit(deletedCount = 1, hasMore = true), + assertIs>( + store.purgeAuditEvents(PurgeAuditEventsCommand(cutoff, maximumEvents = 1)) + ).value + ) + assertEquals( + PurgeAuditEventsCommit(deletedCount = 1, hasMore = false), + assertIs>( + store.purgeAuditEvents(PurgeAuditEventsCommand(cutoff, maximumEvents = 1)) + ).value + ) + assertEquals( + "018f0f2e-7b00-7000-8000-000000000023", + pool.query("SELECT id FROM aether_identity.audit_events") + .execute().coAwait().single().getString("id") + ) + assertEquals( + PurgeAuditEventsCommit(deletedCount = 0, hasMore = false), + assertIs>( + store.purgeAuditEvents(PurgeAuditEventsCommand(cutoff)) + ).value + ) + } finally { + pool.close().coAwait() + vertx.close().coAwait() + withContext(Dispatchers.IO) { postgres.stop() } + } + } +} diff --git a/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStoreIntegrationTest.kt b/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStoreIntegrationTest.kt new file mode 100644 index 0000000..e4055fa --- /dev/null +++ b/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlIdentityStoreIntegrationTest.kt @@ -0,0 +1,2183 @@ +package codes.yousef.aether.auth.postgresql + +import codes.yousef.aether.auth.* +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.IdentityStoreConformanceSuite +import io.vertx.core.Vertx +import io.vertx.core.json.JsonObject as VertxJsonObject +import io.vertx.kotlin.coroutines.coAwait +import io.vertx.pgclient.PgBuilder +import io.vertx.pgclient.PgConnectOptions +import io.vertx.sqlclient.PoolOptions +import io.vertx.sqlclient.SqlClient +import io.vertx.sqlclient.Tuple +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.buildJsonObject +import org.testcontainers.containers.PostgreSQLContainer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PostgresqlIdentityStoreIntegrationTest { + @Test + fun migrationAndDirectTransportPreserveAtomicStoreSemantics() = runBlocking { + val postgres = PostgreSQLContainer("postgres:16-alpine").apply { + withDatabaseName("aether_identity_test") + withUsername("aether") + withPassword("aether-test-password") + } + withContext(Dispatchers.IO) { postgres.start() } + + val vertx = Vertx.vertx() + val pool = PgBuilder.pool() + .with(PoolOptions().setMaxSize(8)) + .connectingTo( + PgConnectOptions() + .setHost(postgres.host) + .setPort(postgres.firstMappedPort) + .setDatabase(postgres.databaseName) + .setUser(postgres.username) + .setPassword(postgres.password) + ) + .using(vertx) + .build() + + try { + pool.query("SELECT 1").execute().coAwait() + val foundationRunner = PostgresqlMigrationRunner( + pool, + PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCE + ) + val foundationMigration = assertIs>( + foundationRunner.migrate() + ) + assertEquals(1, foundationMigration.value.version) + assertEquals(true, foundationMigration.value.applied) + + val migrationRunner = PostgresqlMigrationRunner(pool) + val currentMigrationResult = migrationRunner.migrate() + val currentMigration = assertIs>( + currentMigrationResult, + "Migration failed safely: $currentMigrationResult" + ) + assertEquals(11, currentMigration.value.version) + assertEquals(true, currentMigration.value.applied) + val secondMigration = assertIs>(migrationRunner.migrate()) + assertEquals(false, secondMigration.value.applied) + assertEquals(currentMigration.value.checksum, secondMigration.value.checksum) + assertEquals( + 11L, + pool.query( + "SELECT COUNT(*) AS count FROM aether_identity.schema_migrations " + + "WHERE module = 'aether-auth-postgresql'" + ).execute().coAwait().iterator().next().getLong("count") + ) + val reviewedMigrations = assertNotNull(loadPackagedPostgresqlMigrations()) + val storedMigrationChecksums = pool.query( + "SELECT version, checksum FROM aether_identity.schema_migrations " + + "WHERE module = 'aether-auth-postgresql' ORDER BY version" + ).execute().coAwait().map { row -> + row.getInteger("version") to row.getString("checksum") + } + assertEquals( + reviewedMigrations.map { migration -> migration.version to migration.checksum }, + storedMigrationChecksums + ) + assertEquals( + false, + pool.query( + "SELECT has_schema_privilege(" + + "'public', 'aether_identity_internal', 'USAGE') AS allowed" + ).execute().coAwait().single().getBoolean("allowed") + ) + assertEquals( + 0L, + pool.query( + "SELECT COUNT(*) AS count FROM pg_catalog.pg_proc p " + + "JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace " + + "WHERE n.nspname = 'aether_identity_internal' " + + "AND p.proname IN ('v1_complete_credential_registration', " + + "'v1_complete_credential_authentication', " + + "'v1_quarantine_credential_authentication', " + + "'v1_complete_recovery_enrollment', " + + "'resolve_web_authn_attempt', 'web_authn_store_error_code') " + + "AND has_function_privilege('public', p.oid, 'EXECUTE')" + ).execute().coAwait().single().getLong("count") + ) + assertEquals( + false, + pool.query( + "SELECT has_function_privilege(" + + "'public', 'aether_identity.v1_revoke_federated_sessions(jsonb)', " + + "'EXECUTE') AS allowed" + ).execute().coAwait().single().getBoolean("allowed") + ) + assertEquals( + 0L, + pool.query( + "SELECT COUNT(*) AS count FROM pg_catalog.pg_proc p " + + "JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace " + + "WHERE n.nspname = 'aether_identity_internal' " + + "AND p.proname IN ('federation_provider_lease', " + + "'require_federation_provider_lease', 'require_federated_session') " + + "AND has_function_privilege('public', p.oid, 'EXECUTE')" + ).execute().coAwait().single().getLong("count") + ) + assertEquals( + 5L, + pool.query( + "SELECT COUNT(*) AS count FROM pg_catalog.pg_proc p " + + "JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace " + + "WHERE n.nspname = 'aether_identity' " + + "AND p.proname IN ('v1_find_federation_provider_control', " + + "'v1_find_federation_provider_control_by_storage_key', " + + "'v1_acquire_federation_provider_lease', " + + "'v1_validate_federation_provider_lease', " + + "'v1_compare_and_set_federation_provider_state')" + ).execute().coAwait().single().getLong("count") + ) + assertEquals( + 1L, + pool.query( + "SELECT COUNT(*) AS count FROM information_schema.columns " + + "WHERE table_schema = 'aether_identity' AND table_name = 'sessions' " + + "AND column_name = 'federation_provider_session_epoch'" + ).execute().coAwait().single().getLong("count") + ) + assertEquals( + 4L, + pool.query( + "SELECT COUNT(*) AS count FROM pg_catalog.pg_proc p " + + "JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace " + + "WHERE n.nspname = 'aether_identity' " + + "AND p.proname IN ('v1_complete_credential_registration', " + + "'v1_complete_credential_authentication', " + + "'v1_quarantine_credential_authentication', " + + "'v1_complete_recovery_enrollment') " + + "AND p.prosecdef AND p.proconfig = ARRAY['search_path=pg_catalog']::text[]" + ).execute().coAwait().single().getLong("count") + ) + + val config = PostgresqlIdentityConfig(IdentityEnvironment.TEST, "aether_test") + val store = PostgresqlIdentityStore(config, VertxPostgresqlRpcTransport(config, pool)) + val missingMarker = assertIs(store.initialize()) + assertEquals(IdentityStoreErrorCode.INTERNAL, missingMarker.error.code) + assertEquals( + false, + pool.query( + "SELECT has_function_privilege(" + + "'public', 'aether_identity.provision_environment(text,text)', 'EXECUTE') AS allowed" + ).execute().coAwait().single().getBoolean("allowed") + ) + provisionEnvironment(pool, IdentityEnvironment.TEST, "aether_test") + provisionEnvironment(pool, IdentityEnvironment.TEST, "aether_test") + val mismatchedProvision = try { + provisionEnvironment(pool, IdentityEnvironment.DEVELOPMENT, "aether_development") + null + } catch (failure: io.vertx.pgclient.PgException) { + failure + } + assertEquals("A0001", assertNotNull(mismatchedProvision).sqlState) + + val wireJson = defaultPostgresqlJson() + val environmentRequest = PostgresqlRpcRequestEnvelope( + operation = PostgresqlRpcOperation.ASSERT_ENVIRONMENT.wireName, + environment = IdentityEnvironment.TEST, + namespace = "aether_test", + payload = buildJsonObject { } + ) + val wireRows = pool.preparedQuery( + "SELECT aether_identity.v1_assert_environment(\$1::jsonb)::text AS response" + ).execute(Tuple.of(VertxJsonObject(wireJson.encodeToString(environmentRequest)))).coAwait() + val wireResponseText = wireRows.iterator().next().getString("response") + val wireResponse = wireJson.decodeFromString(wireResponseText) + assertEquals(PostgresqlRpcOutcome.SUCCESS, wireResponse.outcome) + + val initialization = store.initialize() + assertIs>(initialization, "Initialization failed safely: $initialization") + + val user = verifyConcurrentBootstrap(store, pool) + IdentityStoreConformanceSuite(store, "postgresql-real").runAll() + verifyFederationJitRaceLeavesNoOrphans(store) + + val scimUser = IdentityFixtures.user(IdentityFixtures.userId("user-scim")) + val scimAudit = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId("audit-scim-create-user"), + action = AuditAction.SCIM_MUTATION_APPLIED, + targetId = scimUser.id.value + ) + val scimCommand = + ApplyScimMutationCommand(IdentityFixtures.scimMutation(user = scimUser), scimAudit) + val scimCreate = store.applyScimMutation(scimCommand) + val scimCreateCommit = assertIs>( + scimCreate, + "SCIM user creation failed safely: $scimCreate" + ) + assertEquals(false, scimCreateCommit.value.alreadyApplied) + val scimRetry = assertIs>( + store.applyScimMutation(scimCommand) + ) + assertEquals(true, scimRetry.value.alreadyApplied) + assertEquals(null, scimRetry.value.auditEvent) + assertEquals(scimUser, assertIs>(store.findUser(scimUser.id)).value) + assertEquals( + scimUser, + assertIs>(store.findUserByEmail(scimUser.primaryEmail!!)).value + ) + + val challenge = IdentityFixtures.challenge(id = IdentityFixtures.challengeId("challenge-concurrent")) + assertIs>(store.createChallenge(CreateChallengeCommand(challenge))) + val consume = codes.yousef.aether.auth.ConsumeChallengeCommand( + challengeId = challenge.id, + expectedVersion = 0, + terminalState = codes.yousef.aether.auth.ChallengeState.CONSUMED, + consumedAt = IdentityFixtures.instant(1_000) + ) + val concurrentResults = listOf( + async(Dispatchers.Default) { store.consumeChallenge(consume) }, + async(Dispatchers.Default) { store.consumeChallenge(consume) } + ).awaitAll() + assertEquals(1, concurrentResults.count { it is StoreResult.Success }) + val raceFailure = assertIs(concurrentResults.single { it is StoreResult.Failure }) + assertEquals(IdentityStoreErrorCode.VERSION_CONFLICT, raceFailure.error.code) + + val firstRegistrationChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("challenge-register-1"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION + ) + val secondRegistrationChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("challenge-register-2"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION + ) + assertIs>( + store.createChallenge(CreateChallengeCommand(firstRegistrationChallenge)) + ) + assertIs>( + store.createChallenge(CreateChallengeCommand(secondRegistrationChallenge)) + ) + + val firstCredential = IdentityFixtures.credential( + id = IdentityFixtures.credentialId("credential-internal-1"), + signCount = 10 + ) + val firstRegistration = CompleteCredentialRegistrationCommand( + challengeId = firstRegistrationChallenge.id, + expectedChallengeVersion = 0, + credential = firstCredential, + auditEvent = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId("audit-register-1"), + action = AuditAction.CREDENTIAL_REGISTERED + ), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit(firstRegistrationChallenge.id) + ) + assertIs>(store.completeCredentialRegistration(firstRegistration)) + assertEquals( + firstCredential, + assertIs>( + store.findCredentialByWebAuthnId(firstCredential.webAuthnId) + ).value + ) + + val duplicateCredential = IdentityFixtures.credential( + id = IdentityFixtures.credentialId("credential-internal-2"), + webAuthnId = firstCredential.webAuthnId + ) + val duplicateResult = store.completeCredentialRegistration( + CompleteCredentialRegistrationCommand( + challengeId = secondRegistrationChallenge.id, + expectedChallengeVersion = 0, + credential = duplicateCredential, + auditEvent = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId("audit-register-2"), + action = AuditAction.CREDENTIAL_REGISTERED + ), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit(secondRegistrationChallenge.id) + ) + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertIs>>( + duplicateResult + ).value.rejection?.error?.code + ) + assertEquals( + ChallengeState.FAILED, + assertIs>(store.findChallenge(secondRegistrationChallenge.id)) + .value.let { it as codes.yousef.aether.auth.Challenge }.state + ) + + val immutableBackupEligibilityChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("challenge-auth-immutable-backup-eligibility") + ) + assertIs>( + store.createChallenge(CreateChallengeCommand(immutableBackupEligibilityChallenge)) + ) + val authenticationAt = IdentityFixtures.instant(1_500) + val backupEligibilityChange = store.completeCredentialAuthentication( + CompleteCredentialAuthenticationCommand( + challengeId = immutableBackupEligibilityChallenge.id, + expectedChallengeVersion = 0, + credentialId = firstCredential.id, + expectedCredentialVersion = 0, + newSignCount = 11, + backupEligible = true, + backedUp = false, + authenticatedAt = authenticationAt, + session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-invalid-backup-eligibility"), + createdAt = authenticationAt + ), + auditEvent = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId("audit-auth-invalid-backup-eligibility"), + action = AuditAction.CREDENTIAL_AUTHENTICATED, + targetId = firstCredential.id.value + ).copy(occurredAt = authenticationAt), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit( + immutableBackupEligibilityChallenge.id, + authenticationAt + ) + ) + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertIs>>( + backupEligibilityChange + ).value.rejection?.error?.code + ) + assertEquals( + ChallengeState.FAILED, + assertIs>( + store.findChallenge(immutableBackupEligibilityChallenge.id) + ).value.let { it as codes.yousef.aether.auth.Challenge }.state + ) + + val quarantineChallenge = IdentityFixtures.challenge(id = IdentityFixtures.challengeId("challenge-quarantine")) + assertIs>( + store.createChallenge(CreateChallengeCommand(quarantineChallenge)) + ) + val detectedAt = IdentityFixtures.instant(2_000) + val quarantineAudit = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId("audit-quarantine"), + action = AuditAction.CREDENTIAL_QUARANTINED, + targetId = firstCredential.id.value + ).copy(outcome = AuditOutcome.DENIED, occurredAt = detectedAt) + val quarantineBackupEligibilityChange = store.quarantineCredentialAuthentication( + QuarantineCredentialAuthenticationCommand( + challengeId = quarantineChallenge.id, + expectedChallengeVersion = 0, + credentialId = firstCredential.id, + expectedCredentialVersion = 0, + observedSignCount = 9, + backupEligible = true, + backedUp = false, + detectedAt = detectedAt, + auditEvent = quarantineAudit, + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit( + quarantineChallenge.id, + detectedAt + ) + ) + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertIs>>( + quarantineBackupEligibilityChange + ).value.rejection?.error?.code + ) + val validQuarantineChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("challenge-quarantine-valid") + ) + assertIs>( + store.createChallenge(CreateChallengeCommand(validQuarantineChallenge)) + ) + val quarantineAttempt = assertIs< + StoreResult.Success> + >( + store.quarantineCredentialAuthentication( + QuarantineCredentialAuthenticationCommand( + challengeId = validQuarantineChallenge.id, + expectedChallengeVersion = 0, + credentialId = firstCredential.id, + expectedCredentialVersion = 0, + observedSignCount = 9, + backupEligible = false, + backedUp = false, + detectedAt = detectedAt, + auditEvent = quarantineAudit.copy( + id = IdentityFixtures.auditEventId("audit-quarantine-valid") + ), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit( + validQuarantineChallenge.id, + detectedAt + ) + ) + ) + ) + val quarantine = requireNotNull(quarantineAttempt.value.completion) + assertEquals(codes.yousef.aether.auth.CredentialState.SUSPECTED_CLONE, quarantine.credential.state) + assertEquals(9, quarantine.credential.signCount) + assertEquals("signature_counter_anomaly", quarantine.credential.revocationReasonCode) + assertNotNull(quarantine.challenge.consumedAt) + + val renamedAt = IdentityFixtures.instant(2_500) + val renamedCredential = quarantine.credential.copy( + name = "Renamed security key", + version = quarantine.credential.version + 1, + updatedAt = renamedAt + ) + assertEquals( + renamedCredential, + assertIs>( + store.mutateCredential( + MutateCredentialCommand( + credentialId = renamedCredential.id, + expectedVersion = quarantine.credential.version, + replacement = renamedCredential, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-credential-rename"), + AuditAction.CREDENTIAL_RENAMED, + renamedCredential.id.value + ).copy( + target = AuditTarget(AuditTargetType.CREDENTIAL, renamedCredential.id.value), + occurredAt = renamedAt + ) + ) + ) + ).value + ) + val revokedCredential = renamedCredential.copy( + state = CredentialState.REVOKED, + version = renamedCredential.version + 1, + updatedAt = IdentityFixtures.instant(2_600), + revokedAt = IdentityFixtures.instant(2_600), + revocationReasonCode = "user_revoked" + ) + assertEquals( + revokedCredential, + assertIs>( + store.mutateCredential( + MutateCredentialCommand( + credentialId = revokedCredential.id, + expectedVersion = renamedCredential.version, + replacement = revokedCredential, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-credential-revoke"), + AuditAction.CREDENTIAL_REVOKED, + revokedCredential.id.value + ).copy( + target = AuditTarget(AuditTargetType.CREDENTIAL, revokedCredential.id.value), + occurredAt = revokedCredential.revokedAt!! + ) + ) + ) + ).value + ) + + val organization = IdentityFixtures.organization() + val owner = IdentityFixtures.membership() + val organizationAudit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-organization-create"), + AuditAction.ORGANIZATION_CREATED, + organization.id.value + ).copy(target = AuditTarget(AuditTargetType.ORGANIZATION, organization.id.value)) + assertIs>( + store.createOrganization(CreateOrganizationCommand(organization, owner, organizationAudit)) + ) + assertEquals( + organization, + assertIs>(store.findOrganizationBySlug(organization.slug)).value + ) + assertEquals( + setOf(organization.id, IdentityFixtures.organizationId("organization-bootstrap")), + assertIs>>( + store.listOrganizationsForUser(user.id) + ).value.map { it.id }.toSet() + ) + + val secondUser = IdentityFixtures.user(IdentityFixtures.userId("user-2")) + assertIs>( + store.applyScimMutation( + ApplyScimMutationCommand( + IdentityFixtures.scimMutation(IdentityFixtures.scimOperationId("scim-operation-user-2"), secondUser), + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-create-user-2"), + AuditAction.SCIM_MUTATION_APPLIED, + secondUser.id.value + ) + ) + ) + ) + val secondOwner = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("membership-owner-2"), + userId = secondUser.id + ) + assertIs>( + store.createMembership( + CreateMembershipCommand( + secondOwner, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-membership-owner-2"), + AuditAction.MEMBERSHIP_CREATED, + secondOwner.id.value + ) + ) + ) + ) + val epochSession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-membership-epoch"), + createdAt = IdentityFixtures.instant(3_000) + ) + assertIs>( + store.createSession( + CreateSessionCommand( + epochSession, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-session-membership-epoch"), + AuditAction.SESSION_CREATED, + epochSession.id.value + ).copy(occurredAt = epochSession.createdAt) + ) + ) + ) + val membershipChangedAt = IdentityFixtures.instant(4_000) + val demotedOwner = owner.copy( + role = OrganizationRole.ADMIN, + version = owner.version + 1, + updatedAt = membershipChangedAt + ) + assertIs>( + store.mutateMembership( + MutateMembershipCommand( + membershipId = owner.id, + expectedVersion = owner.version, + replacement = demotedOwner, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-owner-demote-safe"), + AuditAction.MEMBERSHIP_CHANGED, + owner.id.value + ).copy(occurredAt = membershipChangedAt), + expectedUserVersion = user.version, + expectedSessionEpoch = user.sessionEpoch, + newSessionEpoch = user.sessionEpoch + 1, + sessionsRevokedAt = membershipChangedAt, + sessionRevocationReasonCode = "organization_privilege_changed" + ) + ) + ) + assertEquals( + 1, + assertIs>(store.findUser(user.id)).value?.sessionEpoch + ) + assertEquals( + SessionState.REVOKED, + assertIs>(store.findSession(epochSession.id)).value?.state + ) + assertEquals( + 2, + assertIs>>( + store.listMembershipsForOrganization(organization.id) + ).value.size + ) + + val invitation = IdentityFixtures.invitation() + val invitationAudit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-invitation-create"), + AuditAction.INVITATION_CREATED, + invitation.id.value + ).copy(target = AuditTarget(AuditTargetType.INVITATION, invitation.id.value)) + assertIs>( + store.createInvitation(CreateInvitationCommand(invitation, invitationAudit)) + ) + assertEquals( + invitation, + assertIs>( + store.findInvitationByTokenDigest(invitation.tokenDigest) + ).value + ) + assertEquals( + listOf(invitation), + assertIs>>( + store.listInvitationsForOrganization(organization.id) + ).value + ) + val revokedInvitation = invitation.copy( + state = InvitationState.REVOKED, + version = 1, + revokedAt = IdentityFixtures.instant(4_500) + ) + assertEquals( + revokedInvitation, + assertIs>( + store.mutateInvitation( + MutateInvitationCommand( + invitation.id, + invitation.version, + revokedInvitation, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-invitation-revoke"), + AuditAction.INVITATION_REVOKED, + invitation.id.value + ).copy(target = AuditTarget(AuditTargetType.INVITATION, invitation.id.value)) + ) + ) + ).value + ) + + verifyOrganizationAuditAndInvitationEnrollment(store, organization, secondUser) + + val serviceIdentity = IdentityFixtures.serviceIdentity() + val initialServiceCredential = IdentityFixtures.serviceCredential() + val serviceCreateAudit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-service-create"), + AuditAction.SERVICE_IDENTITY_CREATED, + serviceIdentity.id.value + ).copy(target = AuditTarget(AuditTargetType.SERVICE_IDENTITY, serviceIdentity.id.value)) + val serviceIdentityCreate = store.createServiceIdentity( + CreateServiceIdentityCommand(serviceIdentity, initialServiceCredential, serviceCreateAudit) + ) + assertIs>( + serviceIdentityCreate, + "Service identity creation failed safely: $serviceIdentityCreate" + ) + assertEquals( + listOf(serviceIdentity), + assertIs>>( + store.listServiceIdentitiesForOrganization(organization.id) + ).value + ) + val rotatedServiceAt = IdentityFixtures.instant(5_000) + val replacementServiceCredential = IdentityFixtures.serviceCredential( + id = IdentityFixtures.serviceCredentialId("service-credential-2") + ).copy( + createdAt = rotatedServiceAt, + expiresAt = IdentityFixtures.instant(86_405_000) + ) + val serviceRotation = assertIs>( + store.rotateServiceCredential( + RotateServiceCredentialCommand( + initialServiceCredential.id, + initialServiceCredential.version, + replacementServiceCredential, + rotatedServiceAt, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-service-rotate"), + AuditAction.SERVICE_CREDENTIAL_ROTATED, + initialServiceCredential.id.value + ).copy(occurredAt = rotatedServiceAt) + ) + ) + ).value + assertEquals(rotatedServiceAt, serviceRotation.previous.rotatedAt) + assertEquals( + 2, + assertIs>>( + store.listServiceCredentialsForIdentity(serviceIdentity.id) + ).value.size + ) + val serviceRevokedAt = IdentityFixtures.instant(5_500) + val revokedServiceIdentity = serviceIdentity.copy( + state = ServiceIdentityState.REVOKED, + version = 1, + updatedAt = serviceRevokedAt, + revokedAt = serviceRevokedAt + ) + assertIs>( + store.mutateServiceIdentity( + MutateServiceIdentityCommand( + serviceIdentity.id, + serviceIdentity.version, + revokedServiceIdentity, + serviceRevokedAt, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-service-revoke"), + AuditAction.SERVICE_IDENTITY_REVOKED, + serviceIdentity.id.value + ).copy( + target = AuditTarget(AuditTargetType.SERVICE_IDENTITY, serviceIdentity.id.value), + occurredAt = serviceRevokedAt + ) + ) + ) + ) + assertTrue( + assertIs>>( + store.listServiceCredentialsForIdentity(serviceIdentity.id) + ).value.all { it.state == ServiceCredentialState.REVOKED } + ) + + val verifyRecoveryFlows: suspend () -> Unit = { + val initialRecoveryCodes = (1..10).map { index -> + IdentityFixtures.recoveryCode( + id = IdentityFixtures.recoveryCodeId("recovery-initial-$index"), + userId = user.id, + generation = 0 + ) + } + assertIs>( + store.replaceRecoveryCodes( + ReplaceRecoveryCodesCommand( + user.id, + expectedGeneration = null, + newGeneration = 0, + codes = initialRecoveryCodes, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-recovery-codes-initial"), + AuditAction.RECOVERY_CODES_REPLACED, + user.id.value + ) + ) + ) + ) + assertEquals( + initialRecoveryCodes.toSet(), + assertIs>>( + store.listRecoveryCodesForUser(user.id) + ).value.toSet() + ) + + val administrativeTicket = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("challenge-administrative-recovery"), + purpose = ChallengePurpose.ACCOUNT_RECOVERY, + userId = user.id + ) + assertIs>( + store.createChallenge(CreateChallengeCommand(administrativeTicket)) + ) + val ticketCreatedAudit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-administrative-ticket-created"), + AuditAction.RECOVERY_ADMIN_TICKET_CREATED, + user.id.value + ) + assertEquals( + ticketCreatedAudit, + assertIs>( + store.appendAuditEvent(ticketCreatedAudit) + ).value + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertIs(store.appendAuditEvent(ticketCreatedAudit)).error.code + ) + val ticketActivatedAt = IdentityFixtures.instant(9_000) + val ticketDeliveryAudit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-administrative-ticket-delivered"), + AuditAction.RECOVERY_ADMIN_TICKET_DELIVERED, + user.id.value + ).copy(occurredAt = ticketActivatedAt) + val activatedTicket = assertIs>( + store.activateAdministrativeRecoveryTicket( + ActivateAdministrativeRecoveryTicketCommand( + challengeId = administrativeTicket.id, + expectedChallengeVersion = administrativeTicket.version, + activatedAt = ticketActivatedAt, + auditEvent = ticketDeliveryAudit + ) + ) + ).value.challenge + assertEquals(ticketActivatedAt, activatedTicket.activatedAt) + val redeemedAt = IdentityFixtures.instant(10_000) + val administrativeRecoverySession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-administrative-recovery"), + userId = user.id, + userSessionEpoch = 1, + assurance = AuthenticationAssurance.RECOVERY, + createdAt = redeemedAt + ) + val redeemTicketCommand = RedeemAdministrativeRecoveryTicketCommand( + challengeId = administrativeTicket.id, + expectedChallengeVersion = activatedTicket.version, + redeemedAt = redeemedAt, + recoverySession = administrativeRecoverySession, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-administrative-ticket-used"), + AuditAction.RECOVERY_ADMIN_TICKET_USED, + user.id.value + ).copy(occurredAt = redeemedAt) + ) + val redeemedTicket = assertIs>( + store.redeemAdministrativeRecoveryTicket(redeemTicketCommand) + ).value + assertEquals(ChallengeState.CONSUMED, redeemedTicket.challenge.state) + assertEquals(administrativeRecoverySession, redeemedTicket.recoverySession) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertIs( + store.redeemAdministrativeRecoveryTicket(redeemTicketCommand) + ).error.code + ) + + val recoveryEnrollmentChallenge = IdentityFixtures.challenge( + id = IdentityFixtures.challengeId("challenge-recovery-enrollment"), + purpose = ChallengePurpose.WEBAUTHN_REGISTRATION, + userId = user.id + ) + assertIs>( + store.createChallenge(CreateChallengeCommand(recoveryEnrollmentChallenge)) + ) + val recoveryCompletedAt = IdentityFixtures.instant(11_000) + val recoveredCredential = IdentityFixtures.credential( + id = IdentityFixtures.credentialId("credential-recovery-enrollment"), + userId = user.id + ).copy(createdAt = recoveryCompletedAt, updatedAt = recoveryCompletedAt) + val replacementRecoveryCodes = (1..10).map { index -> + IdentityFixtures.recoveryCode( + id = IdentityFixtures.recoveryCodeId("recovery-replacement-$index"), + userId = user.id, + generation = 1 + ).copy(createdAt = recoveryCompletedAt) + } + val recoveryEnrollmentCommand = CompleteRecoveryEnrollmentCommand( + challengeId = recoveryEnrollmentChallenge.id, + expectedChallengeVersion = recoveryEnrollmentChallenge.version, + credential = recoveredCredential, + recoverySessionId = administrativeRecoverySession.id, + expectedRecoverySessionVersion = administrativeRecoverySession.version, + expectedUserVersion = 1, + expectedSessionEpoch = 1, + newSessionEpoch = 2, + expectedRecoveryGeneration = 0, + newRecoveryGeneration = 1, + replacementRecoveryCodes = replacementRecoveryCodes, + completedAt = recoveryCompletedAt, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-recovery-enrollment-complete"), + AuditAction.RECOVERY_ENROLLMENT_COMPLETED, + user.id.value + ).copy(occurredAt = recoveryCompletedAt), + rejectionAuditEvent = IdentityFixtures.webAuthnStoreRejectionAudit( + recoveryEnrollmentChallenge.id, + recoveryCompletedAt + ) + ) + val recoveryEnrollmentAttempt = assertIs< + StoreResult.Success> + >( + store.completeRecoveryEnrollment(recoveryEnrollmentCommand) + ).value + val recoveryEnrollment = requireNotNull(recoveryEnrollmentAttempt.completion) + assertEquals(ChallengeState.CONSUMED, recoveryEnrollment.challenge.state) + assertEquals(recoveredCredential, recoveryEnrollment.credential) + assertEquals(listOf(administrativeRecoverySession.id), recoveryEnrollment.revokedSessionIds) + assertEquals(2, recoveryEnrollment.user.sessionEpoch) + assertEquals(1, recoveryEnrollment.recoveryGeneration) + assertEquals(replacementRecoveryCodes, recoveryEnrollment.recoveryCodes) + assertEquals( + SessionState.REVOKED, + assertIs>( + store.findSession(administrativeRecoverySession.id) + ).value?.state + ) + val storedRecoveryCodes = assertIs>>( + store.listRecoveryCodesForUser(user.id) + ).value + assertEquals(20, storedRecoveryCodes.size) + assertEquals(10, storedRecoveryCodes.count { + it.generation == 0L && it.state == RecoveryCodeState.REVOKED + }) + assertEquals(replacementRecoveryCodes.toSet(), storedRecoveryCodes.filter { + it.generation == 1L && it.state == RecoveryCodeState.ACTIVE + }.toSet()) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertIs( + store.completeRecoveryEnrollment(recoveryEnrollmentCommand) + ).error.code + ) + } + verifyRecoveryFlows() + verifyDeviceGrantAndTokenLifecycle(store, user, organization, demotedOwner) + + verifyScimBatchAtomicity( + store = store, + client = pool, + wireJson = wireJson, + user = user, + secondUser = secondUser, + organization = organization, + demotedOwner = demotedOwner, + secondOwner = secondOwner, + otherOrganizationId = IdentityFixtures.organizationId("organization-bootstrap") + ) + + val wrongConfig = PostgresqlIdentityConfig( + environment = IdentityEnvironment.DEVELOPMENT, + namespace = "aether_development" + ) + val wrongStore = PostgresqlIdentityStore( + wrongConfig, + VertxPostgresqlRpcTransport(wrongConfig, pool) + ) + assertEquals( + IdentityStoreErrorCode.INTERNAL, + assertIs(wrongStore.initialize()).error.code + ) + } finally { + pool.close().coAwait() + vertx.close().coAwait() + withContext(Dispatchers.IO) { postgres.stop() } + } + } + + @Test + fun identitySessionTouchIsAtomicAndAuditFree() = runBlocking { + val postgres = PostgreSQLContainer("postgres:16-alpine").apply { + withDatabaseName("aether_identity_touch_test") + withUsername("aether") + withPassword("aether-test-password") + } + withContext(Dispatchers.IO) { postgres.start() } + val vertx = Vertx.vertx() + val pool = PgBuilder.pool() + .with(PoolOptions().setMaxSize(4)) + .connectingTo( + PgConnectOptions() + .setHost(postgres.host) + .setPort(postgres.firstMappedPort) + .setDatabase(postgres.databaseName) + .setUser(postgres.username) + .setPassword(postgres.password) + ) + .using(vertx) + .build() + try { + val migrated = PostgresqlMigrationRunner(pool).migrate() + assertEquals(11, assertIs>(migrated).value.version) + val config = PostgresqlIdentityConfig(IdentityEnvironment.TEST, "aether_touch_test") + provisionEnvironment(pool, IdentityEnvironment.TEST, "aether_touch_test") + val store = PostgresqlIdentityStore(config, VertxPostgresqlRpcTransport(config, pool)) + assertIs>(store.initialize()) + val user = verifyConcurrentBootstrap(store, pool) + verifyIdentitySessionTouch(store, pool, user) + } finally { + pool.close().coAwait() + vertx.close().coAwait() + withContext(Dispatchers.IO) { postgres.stop() } + } + } + + private suspend fun verifyDeviceGrantAndTokenLifecycle( + store: PostgresqlIdentityStore, + user: User, + organization: Organization, + membership: Membership + ) { + val pendingGrant = IdentityFixtures.deviceGrant() + assertIs>( + store.compareAndSetDeviceGrant( + CompareAndSetDeviceGrantCommand( + null, + pendingGrant, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-device-start"), + AuditAction.DEVICE_GRANT_CHANGED, + pendingGrant.id.value + ) + ) + ) + ) + assertEquals( + pendingGrant, + assertIs>( + store.findDeviceGrantByDeviceCodeDigest(pendingGrant.deviceCodeDigest) + ).value + ) + assertEquals( + pendingGrant, + assertIs>( + store.findDeviceGrantByUserCodeDigest(pendingGrant.userCodeDigest) + ).value + ) + val authorizedAt = IdentityFixtures.instant(6_000) + val authorizedGrant = pendingGrant.copy( + approvedCapabilities = pendingGrant.requestedCapabilities, + state = DeviceGrantState.AUTHORIZED, + userId = user.id, + organizationId = organization.id, + membershipId = membership.id, + membershipVersion = membership.version, + authorizedByUserId = user.id, + version = pendingGrant.version + 1, + authorizedAt = authorizedAt + ) + assertIs>( + store.compareAndSetDeviceGrant( + CompareAndSetDeviceGrantCommand( + pendingGrant.version, + authorizedGrant, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-device-authorize"), + AuditAction.DEVICE_GRANT_CHANGED, + authorizedGrant.id.value + ).copy(occurredAt = authorizedAt) + ) + ) + ) + val exchangedAt = IdentityFixtures.instant(7_000) + val family = DeviceTokenFamily( + id = IdentityFixtures.deviceTokenFamilyId("device-family-1"), + deviceGrantId = authorizedGrant.id, + clientId = authorizedGrant.clientId, + userId = user.id, + organizationId = organization.id, + membershipId = membership.id, + membershipVersion = membership.version, + capabilities = authorizedGrant.approvedCapabilities, + createdAt = exchangedAt, + expiresAt = IdentityFixtures.instant(2_592_007_000) + ) + val access = DeviceAccessToken( + id = IdentityFixtures.deviceAccessTokenId("device-access-1"), + familyId = family.id, + publicSelector = "access_selector_1", + secretDigest = IdentityFixtures.digest("device-access-1"), + createdAt = exchangedAt, + expiresAt = IdentityFixtures.instant(907_000) + ) + val refresh = DeviceRefreshToken( + id = IdentityFixtures.deviceRefreshTokenId("device-refresh-1"), + familyId = family.id, + publicSelector = "refresh_selector_1", + secretDigest = IdentityFixtures.digest("device-refresh-1"), + rotationCounter = 0, + createdAt = exchangedAt, + expiresAt = family.expiresAt + ) + val exchangeCommand = ExchangeDeviceGrantCommand( + authorizedGrant.id, + authorizedGrant.version, + family, + access, + refresh, + exchangedAt, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-device-exchange"), + AuditAction.DEVICE_TOKEN_ISSUED, + authorizedGrant.id.value + ).copy( + organizationId = organization.id, + target = AuditTarget(AuditTargetType.DEVICE_GRANT, authorizedGrant.id.value), + occurredAt = exchangedAt + ) + ) + val exchangeRace = coroutineScope { + listOf( + async(Dispatchers.Default) { store.exchangeDeviceGrant(exchangeCommand) }, + async(Dispatchers.Default) { store.exchangeDeviceGrant(exchangeCommand) } + ).awaitAll() + } + assertEquals(1, exchangeRace.count { it is StoreResult.Success }) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertIs(exchangeRace.single { it is StoreResult.Failure }).error.code + ) + assertEquals( + family, + assertIs>(store.findDeviceTokenFamily(family.id)).value + ) + assertEquals( + access, + assertIs>( + store.findDeviceAccessTokenBySelector(access.publicSelector) + ).value + ) + val rotatedAt = IdentityFixtures.instant(8_000) + val replacementAccess = DeviceAccessToken( + id = IdentityFixtures.deviceAccessTokenId("device-access-2"), + familyId = family.id, + publicSelector = "access_selector_2", + secretDigest = IdentityFixtures.digest("device-access-2"), + createdAt = rotatedAt, + expiresAt = IdentityFixtures.instant(908_000) + ) + val replacementRefresh = DeviceRefreshToken( + id = IdentityFixtures.deviceRefreshTokenId("device-refresh-2"), + familyId = family.id, + publicSelector = "refresh_selector_2", + secretDigest = IdentityFixtures.digest("device-refresh-2"), + rotationCounter = 1, + createdAt = rotatedAt, + expiresAt = family.expiresAt + ) + val rotation = assertIs>( + store.rotateDeviceRefreshToken( + RotateDeviceRefreshTokenCommand( + refresh.id, + refresh.version, + family.version, + replacementAccess, + replacementRefresh, + rotatedAt, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-device-refresh"), + AuditAction.DEVICE_TOKEN_REFRESHED, + family.deviceGrantId.value + ).copy( + organizationId = organization.id, + target = AuditTarget(AuditTargetType.DEVICE_GRANT, family.deviceGrantId.value), + occurredAt = rotatedAt + ) + ) + ) + ).value + assertEquals(DeviceRefreshTokenState.ROTATED, rotation.previousRefreshToken.state) + assertEquals( + DeviceRefreshTokenState.ROTATED, + assertIs>( + store.findDeviceRefreshTokenBySelector(refresh.publicSelector) + ).value?.state + ) + val familyRevocation = assertIs>( + store.revokeDeviceTokenFamily( + RevokeDeviceTokenFamilyCommand( + family.id, + family.version, + IdentityFixtures.instant(9_000), + "refresh_replay", + replayDetected = true, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-device-replay"), + AuditAction.DEVICE_TOKEN_REPLAY_DETECTED, + family.deviceGrantId.value + ).copy( + organizationId = organization.id, + target = AuditTarget(AuditTargetType.DEVICE_GRANT, family.deviceGrantId.value), + occurredAt = IdentityFixtures.instant(9_000) + ) + ) + ) + ).value + assertEquals(DeviceTokenFamilyState.REVOKED, familyRevocation.family.state) + assertEquals(listOf(access.id, replacementAccess.id), familyRevocation.revokedAccessTokenIds) + assertEquals(listOf(replacementRefresh.id), familyRevocation.revokedRefreshTokenIds) + assertEquals( + DeviceAccessTokenState.REVOKED, + assertIs>( + store.findDeviceAccessTokenBySelector(replacementAccess.publicSelector) + ).value?.state + ) + } + + private suspend fun verifyOrganizationAuditAndInvitationEnrollment( + store: PostgresqlIdentityStore, + organization: Organization, + existingUser: User + ) { + verifyOrganizationAuditPagination(store, organization.id) + val invitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("invitation-enrollment-race"), + organizationId = organization.id + ).copy(email = EmailAddress("new-pg-invitee@example.test")) + createInvitation(store, invitation, "audit-invitation-enrollment-race-create") + val command = invitationEnrollmentCommand(invitation, "race", IdentityFixtures.instant(20_000)) + val raced = coroutineScope { + listOf( + async(Dispatchers.Default) { store.enrollInvitation(command) }, + async(Dispatchers.Default) { store.enrollInvitation(command) } + ).awaitAll() + } + val commit = assertIs>( + raced.single { it is StoreResult.Success } + ).value + assertEquals(InvitationState.ACCEPTED, commit.invitation.state) + assertEquals(SessionAuthenticationMethod.INVITATION, commit.enrollmentSession.authenticationMethod) + assertEquals(AuthenticationAssurance.RECOVERY, commit.enrollmentSession.assurance) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertIs(raced.single { it is StoreResult.Failure }).error.code + ) + assertEquals(commit.user, assertIs>(store.findUser(commit.user.id)).value) + assertEquals( + commit.membership, + assertIs>(store.findMembership(commit.membership.id)).value + ) + assertEquals( + commit.enrollmentSession, + assertIs>(store.findSession(commit.enrollmentSession.id)).value + ) + + val wrongTokenInvitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("invitation-enrollment-wrong-token"), + organizationId = organization.id + ).copy(email = EmailAddress("wrong-token-pg@example.test")) + createInvitation(store, wrongTokenInvitation, "audit-invitation-enrollment-wrong-token-create") + val wrongTokenCommand = invitationEnrollmentCommand( + wrongTokenInvitation, + "wrong-token", + IdentityFixtures.instant(21_000) + ).copy( + expectedTokenDigest = wrongTokenInvitation.tokenDigest.copy(encoded = "wrong-token-digest") + ) + assertEquals( + IdentityStoreErrorCode.NOT_FOUND, + assertIs(store.enrollInvitation(wrongTokenCommand)).error.code + ) + assertInvitationEnrollmentRolledBack(store, wrongTokenInvitation, wrongTokenCommand) + + val expiredInvitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("invitation-enrollment-expired"), + organizationId = organization.id + ).copy(email = EmailAddress("expired-pg@example.test")) + createInvitation(store, expiredInvitation, "audit-invitation-enrollment-expired-create") + val expiredCommand = invitationEnrollmentCommand( + expiredInvitation, + "expired", + expiredInvitation.expiresAt + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertIs(store.enrollInvitation(expiredCommand)).error.code + ) + assertInvitationEnrollmentRolledBack(store, expiredInvitation, expiredCommand) + + val duplicateEmailInvitation = IdentityFixtures.invitation( + id = IdentityFixtures.invitationId("invitation-enrollment-duplicate-email"), + organizationId = organization.id + ).copy(email = requireNotNull(existingUser.primaryEmail)) + createInvitation(store, duplicateEmailInvitation, "audit-invitation-enrollment-duplicate-email-create") + val duplicateEmailCommand = invitationEnrollmentCommand( + duplicateEmailInvitation, + "duplicate-email", + IdentityFixtures.instant(22_000) + ) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertIs(store.enrollInvitation(duplicateEmailCommand)).error.code + ) + assertInvitationEnrollmentRolledBack(store, duplicateEmailInvitation, duplicateEmailCommand) + } + + private suspend fun createInvitation( + store: PostgresqlIdentityStore, + invitation: Invitation, + auditId: String + ) { + val audit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId(auditId), + AuditAction.INVITATION_CREATED, + invitation.id.value + ).copy( + organizationId = invitation.organizationId, + target = AuditTarget(AuditTargetType.INVITATION, invitation.id.value) + ) + assertEquals( + invitation, + assertIs>( + store.createInvitation(CreateInvitationCommand(invitation, audit)) + ).value + ) + } + + private fun invitationEnrollmentCommand( + invitation: Invitation, + suffix: String, + enrolledAt: kotlin.time.Instant + ): EnrollInvitationCommand { + val user = IdentityFixtures.user(IdentityFixtures.userId("user-invitation-$suffix")).copy( + primaryEmail = invitation.email, + createdAt = enrolledAt, + updatedAt = enrolledAt, + activatedAt = enrolledAt + ) + val membership = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("membership-invitation-$suffix"), + organizationId = invitation.organizationId, + userId = user.id, + role = invitation.role + ).copy(createdAt = enrolledAt, updatedAt = enrolledAt) + val expiresAt = kotlin.time.Instant.fromEpochMilliseconds(enrolledAt.toEpochMilliseconds() + 900_000) + val session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-invitation-$suffix"), + userId = user.id, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.INVITATION, + createdAt = enrolledAt + ).copy(idleExpiresAt = expiresAt, absoluteExpiresAt = expiresAt) + val audit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-invitation-enrollment-$suffix"), + AuditAction.INVITATION_ACCEPTED, + invitation.id.value + ).copy( + organizationId = invitation.organizationId, + target = AuditTarget(AuditTargetType.INVITATION, invitation.id.value), + occurredAt = enrolledAt + ) + return EnrollInvitationCommand( + invitationId = invitation.id, + expectedInvitationVersion = invitation.version, + expectedTokenDigest = invitation.tokenDigest, + user = user, + membership = membership, + enrollmentSession = session, + enrolledAt = enrolledAt, + auditEvent = audit + ) + } + + private suspend fun assertInvitationEnrollmentRolledBack( + store: PostgresqlIdentityStore, + invitation: Invitation, + command: EnrollInvitationCommand + ) { + assertEquals( + InvitationState.PENDING, + assertIs>(store.findInvitation(invitation.id)).value?.state + ) + assertEquals(null, assertIs>(store.findUser(command.user.id)).value) + assertEquals( + null, + assertIs>(store.findMembership(command.membership.id)).value + ) + assertEquals( + null, + assertIs>(store.findSession(command.enrollmentSession.id)).value + ) + } + + private suspend fun verifyScimBatchAtomicity( + store: PostgresqlIdentityStore, + client: SqlClient, + wireJson: kotlinx.serialization.json.Json, + user: User, + secondUser: User, + organization: Organization, + demotedOwner: Membership, + secondOwner: Membership, + otherOrganizationId: OrganizationId + ) { + val provider = "test-scim-batch" + val changedAt = IdentityFixtures.instant(14_000) + val removedSecondOwner = secondOwner.copy( + state = MembershipState.REMOVED, + version = secondOwner.version + 1, + updatedAt = changedAt, + removedAt = changedAt + ) + val promotedOwner = demotedOwner.copy( + role = OrganizationRole.OWNER, + version = demotedOwner.version + 1, + updatedAt = changedAt + ) + val removeSecondOwner = scimMembershipCommand( + operationId = "scim-batch-remove-second-owner", + provider = provider, + type = ScimMutationType.REMOVE_MEMBERSHIP, + membership = removedSecondOwner, + auditId = "audit-scim-batch-remove-second-owner", + occurredAt = changedAt + ) + val promoteFirstOwner = scimMembershipCommand( + operationId = "scim-batch-promote-first-owner", + provider = provider, + type = ScimMutationType.UPSERT_MEMBERSHIP, + membership = promotedOwner, + auditId = "audit-scim-batch-promote-first-owner", + occurredAt = changedAt + ) + val group = ScimGroup( + id = "scim-group-publishers", + organizationId = organization.id, + provider = provider, + externalId = "external-publishers", + displayName = "Publishers", + memberUserIds = setOf(user.id, secondUser.id), + version = 1, + createdAt = changedAt, + updatedAt = changedAt + ) + val groupAudit = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-group-create"), + AuditAction.SCIM_GROUP_CHANGED, + group.id + ).copy( + organizationId = organization.id, + target = AuditTarget(AuditTargetType.SCIM_GROUP, group.id), + occurredAt = changedAt + ) + val groupCreateCommand = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("scim-batch-group-create"), + organizationId = organization.id, + provider = provider, + // This order temporarily removes the only owner; only the final state is authoritative. + mutations = listOf(removeSecondOwner, promoteFirstOwner), + group = group, + expectedGroupVersion = 0, + auditEvent = groupAudit + ) + val groupCreate = assertIs>( + store.applyScimBatch(groupCreateCommand) + ).value + assertEquals(false, groupCreate.alreadyApplied) + assertEquals(group, groupCreate.group) + assertTrue(groupCreate.mutationCommits.all { !it.alreadyApplied && it.auditEvent != null }) + assertEquals( + MembershipState.REMOVED, + assertIs>(store.findMembership(secondOwner.id)).value?.state + ) + assertEquals( + OrganizationRole.OWNER, + assertIs>(store.findMembership(demotedOwner.id)).value?.role + ) + assertEquals( + group, + assertIs>( + store.findScimGroup(provider, organization.id, group.id) + ).value + ) + assertEquals( + null, + assertIs>( + store.findScimGroup("other-scim-provider", organization.id, group.id) + ).value + ) + + val groupReplay = assertIs>( + store.applyScimBatch(groupCreateCommand) + ).value + assertTrue(groupReplay.alreadyApplied) + assertEquals(null, groupReplay.auditEvent) + assertTrue(groupReplay.mutationCommits.all { it.alreadyApplied && it.auditEvent == null }) + val changedFingerprint = groupCreateCommand.copy( + auditEvent = groupAudit.copy(reasonCode = "changed_command") + ) + assertEquals( + IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT, + assertIs(store.applyScimBatch(changedFingerprint)).error.code + ) + + val updatedAt = IdentityFixtures.instant(14_500) + val updatedGroup = group.copy( + displayName = "Release Publishers", + version = 2, + updatedAt = updatedAt + ) + val updateGroupCommand = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("scim-batch-group-update"), + organizationId = organization.id, + provider = provider, + group = updatedGroup, + expectedGroupVersion = 1, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-group-update"), + AuditAction.SCIM_GROUP_CHANGED, + group.id + ).copy( + organizationId = organization.id, + target = AuditTarget(AuditTargetType.SCIM_GROUP, group.id), + occurredAt = updatedAt + ) + ) + assertEquals( + updatedGroup, + assertIs>( + store.applyScimBatch(updateGroupCommand) + ).value.group + ) + val staleAt = IdentityFixtures.instant(15_000) + val staleGroupCommand = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("scim-batch-group-stale-update"), + organizationId = organization.id, + provider = provider, + group = updatedGroup.copy(displayName = "Stale Publishers", updatedAt = staleAt), + expectedGroupVersion = 1, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-group-stale-update"), + AuditAction.SCIM_GROUP_CHANGED, + group.id + ).copy( + organizationId = organization.id, + target = AuditTarget(AuditTargetType.SCIM_GROUP, group.id), + occurredAt = staleAt + ) + ) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertIs(store.applyScimBatch(staleGroupCommand)).error.code + ) + + val rollbackAt = IdentityFixtures.instant(15_500) + val rollbackUser = IdentityFixtures.user(IdentityFixtures.userId("user-scim-batch-rollback")) + val createRollbackUser = ApplyScimMutationCommand( + ScimMutation( + operationId = IdentityFixtures.scimOperationId("scim-batch-create-rollback-user"), + provider = provider, + type = ScimMutationType.UPSERT_USER, + externalSubject = ExternalSubject("rollback-user"), + user = rollbackUser, + occurredAt = rollbackAt + ), + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-batch-create-rollback-user"), + AuditAction.SCIM_MUTATION_APPLIED, + rollbackUser.id.value + ).copy(organizationId = organization.id, occurredAt = rollbackAt) + ) + val removeLastOwner = scimMembershipCommand( + operationId = "scim-batch-remove-last-owner", + provider = provider, + type = ScimMutationType.REMOVE_MEMBERSHIP, + membership = promotedOwner.copy( + state = MembershipState.REMOVED, + version = promotedOwner.version + 1, + updatedAt = rollbackAt, + removedAt = rollbackAt + ), + auditId = "audit-scim-batch-remove-last-owner", + occurredAt = rollbackAt + ) + val rejectedLastOwnerBatch = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("scim-batch-last-owner-rejected"), + organizationId = organization.id, + provider = provider, + mutations = listOf(createRollbackUser, removeLastOwner), + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-batch-last-owner-rejected"), + AuditAction.SCIM_MUTATION_APPLIED, + organization.id.value + ).copy(organizationId = organization.id, occurredAt = rollbackAt) + ) + assertEquals( + IdentityStoreErrorCode.LAST_OWNER, + assertIs(store.applyScimBatch(rejectedLastOwnerBatch)).error.code + ) + assertEquals( + null, + assertIs>(store.findUser(rollbackUser.id)).value + ) + assertEquals( + promotedOwner, + assertIs>(store.findMembership(promotedOwner.id)).value + ) + + val sessionUser = assertIs>(store.findUser(user.id)).value + ?: error("SCIM revocation user is missing") + val targetProviderKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "postgresql-scim-target" + ) + val targetProviderLease = assertIs>( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organizationId = organization.id, + kind = FederationProviderKind.OIDC, + providerId = "scim-target", + storageKey = targetProviderKey, + acquiredAt = IdentityFixtures.instant(15_600) + ) + ) + ).value + val controlProviderKey = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.OIDC, + "postgresql-scim-control" + ) + val controlProviderLease = assertIs>( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organizationId = otherOrganizationId, + kind = FederationProviderKind.OIDC, + providerId = "scim-control", + storageKey = controlProviderKey, + acquiredAt = IdentityFixtures.instant(15_610) + ) + ) + ).value + val targetSession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-federated-other-provider"), + userId = user.id, + userSessionEpoch = sessionUser.sessionEpoch, + assurance = AuthenticationAssurance.SESSION, + authenticationMethod = SessionAuthenticationMethod.OIDC, + federationOrganizationId = organization.id, + federationProviderKey = targetProviderKey, + federationProviderSessionEpoch = targetProviderLease.sessionEpoch, + externalIdentityId = IdentityFixtures.externalIdentityId("scim-target-session"), + createdAt = IdentityFixtures.instant(15_700) + ) + val otherTenantSession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-federated-other-tenant"), + userId = user.id, + userSessionEpoch = sessionUser.sessionEpoch, + assurance = AuthenticationAssurance.SESSION, + authenticationMethod = SessionAuthenticationMethod.OIDC, + federationOrganizationId = otherOrganizationId, + federationProviderKey = controlProviderKey, + federationProviderSessionEpoch = controlProviderLease.sessionEpoch, + externalIdentityId = IdentityFixtures.externalIdentityId("scim-control-session"), + createdAt = IdentityFixtures.instant(15_710) + ) + val passkeyControlSession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-federated-passkey-control"), + userId = user.id, + userSessionEpoch = sessionUser.sessionEpoch, + createdAt = IdentityFixtures.instant(15_720) + ) + listOf(targetSession, otherTenantSession, passkeyControlSession).forEachIndexed { index, session -> + assertIs>( + store.createSession( + CreateSessionCommand( + session = session, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-revocation-session-$index"), + AuditAction.SESSION_CREATED, + session.id.value + ).copy( + organizationId = session.federationOrganizationId, + target = AuditTarget(AuditTargetType.SESSION, session.id.value), + occurredAt = session.createdAt + ) + ) + ) + ) + } + + val deviceCreatedAt = IdentityFixtures.instant(16_000) + val targetFamily = DeviceTokenFamily( + id = IdentityFixtures.deviceTokenFamilyId("scim-device-family-target"), + deviceGrantId = IdentityFixtures.deviceGrantId(), + clientId = "aether-scim-test-client", + userId = user.id, + organizationId = organization.id, + membershipId = promotedOwner.id, + membershipVersion = promotedOwner.version, + capabilities = setOf(Capability.CONTENT_READ), + createdAt = deviceCreatedAt, + expiresAt = IdentityFixtures.instant(2_592_016_000) + ) + val targetAccess = DeviceAccessToken( + id = IdentityFixtures.deviceAccessTokenId("scim-device-access-target"), + familyId = targetFamily.id, + publicSelector = "scim_access_target", + secretDigest = IdentityFixtures.digest("scim-device-access-target"), + createdAt = deviceCreatedAt, + expiresAt = IdentityFixtures.instant(916_000) + ) + val targetRefresh = DeviceRefreshToken( + id = IdentityFixtures.deviceRefreshTokenId("scim-device-refresh-target"), + familyId = targetFamily.id, + publicSelector = "scim_refresh_target", + secretDigest = IdentityFixtures.digest("scim-device-refresh-target"), + rotationCounter = 0, + createdAt = deviceCreatedAt, + expiresAt = targetFamily.expiresAt + ) + val controlFamily = targetFamily.copy( + id = IdentityFixtures.deviceTokenFamilyId("scim-device-family-control"), + organizationId = otherOrganizationId, + membershipId = IdentityFixtures.membershipId("membership-bootstrap-owner"), + membershipVersion = 0 + ) + val controlAccess = targetAccess.copy( + id = IdentityFixtures.deviceAccessTokenId("scim-device-access-control"), + familyId = controlFamily.id, + publicSelector = "scim_access_control", + secretDigest = IdentityFixtures.digest("scim-device-access-control") + ) + val controlRefresh = targetRefresh.copy( + id = IdentityFixtures.deviceRefreshTokenId("scim-device-refresh-control"), + familyId = controlFamily.id, + publicSelector = "scim_refresh_control", + secretDigest = IdentityFixtures.digest("scim-device-refresh-control") + ) + insertScimDeviceArtifacts(client, wireJson, targetFamily, targetAccess, targetRefresh) + insertScimDeviceArtifacts(client, wireJson, controlFamily, controlAccess, controlRefresh) + + val revokedAt = IdentityFixtures.instant(17_000) + val revocationCommand = ApplyScimBatchCommand( + operationId = IdentityFixtures.scimOperationId("scim-batch-tenant-revocation"), + organizationId = organization.id, + provider = provider, + revocations = listOf( + ScimTenantRevocation( + userId = user.id, + reasonCode = "scim_membership_deprovisioned" + ) + ), + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-scim-batch-tenant-revocation"), + AuditAction.SCIM_MUTATION_APPLIED, + user.id.value + ).copy(organizationId = organization.id, occurredAt = revokedAt) + ) + val revocation = assertIs>( + store.applyScimBatch(revocationCommand) + ).value + assertEquals(listOf(targetSession.id), revocation.revokedSessionIds) + assertEquals(listOf(targetFamily.id), revocation.revokedDeviceTokenFamilyIds) + assertEquals(listOf(targetAccess.id), revocation.revokedDeviceAccessTokenIds) + assertEquals(listOf(targetRefresh.id), revocation.revokedDeviceRefreshTokenIds) + assertEquals( + SessionState.ACTIVE, + assertIs>( + store.findSession(otherTenantSession.id) + ).value?.state + ) + assertEquals( + SessionState.ACTIVE, + assertIs>( + store.findSession(passkeyControlSession.id) + ).value?.state + ) + assertEquals( + DeviceTokenFamilyState.REVOKED, + assertIs>(store.findDeviceTokenFamily(targetFamily.id)).value?.state + ) + assertEquals( + DeviceTokenFamilyState.ACTIVE, + assertIs>(store.findDeviceTokenFamily(controlFamily.id)).value?.state + ) + assertEquals( + DeviceAccessTokenState.REVOKED, + assertIs>( + store.findDeviceAccessTokenBySelector(targetAccess.publicSelector) + ).value?.state + ) + assertEquals( + DeviceAccessTokenState.ACTIVE, + assertIs>( + store.findDeviceAccessTokenBySelector(controlAccess.publicSelector) + ).value?.state + ) + val revocationReplay = assertIs>( + store.applyScimBatch(revocationCommand) + ).value + assertTrue(revocationReplay.alreadyApplied) + assertEquals(null, revocationReplay.auditEvent) + assertEquals(revocation.revokedSessionIds, revocationReplay.revokedSessionIds) + assertEquals(revocation.revokedDeviceTokenFamilyIds, revocationReplay.revokedDeviceTokenFamilyIds) + } + + private fun scimMembershipCommand( + operationId: String, + provider: String, + type: ScimMutationType, + membership: Membership, + auditId: String, + occurredAt: kotlin.time.Instant + ): ApplyScimMutationCommand = ApplyScimMutationCommand( + mutation = ScimMutation( + operationId = IdentityFixtures.scimOperationId(operationId), + provider = provider, + type = type, + externalSubject = ExternalSubject("subject-$operationId"), + membership = membership, + occurredAt = occurredAt + ), + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId(auditId), + AuditAction.SCIM_MUTATION_APPLIED, + membership.id.value + ).copy(organizationId = membership.organizationId, occurredAt = occurredAt) + ) + + private suspend fun insertScimDeviceArtifacts( + client: SqlClient, + wireJson: kotlinx.serialization.json.Json, + family: DeviceTokenFamily, + access: DeviceAccessToken, + refresh: DeviceRefreshToken + ) { + val inserts = listOf( + "insert_device_token_family" to wireJson.encodeToString(DeviceTokenFamily.serializer(), family), + "insert_device_access_token" to wireJson.encodeToString(DeviceAccessToken.serializer(), access), + "insert_device_refresh_token" to wireJson.encodeToString(DeviceRefreshToken.serializer(), refresh) + ) + inserts.forEach { (function, document) -> + client.preparedQuery("SELECT aether_identity.$function(\$1::jsonb)") + .execute(Tuple.of(VertxJsonObject(document))).coAwait() + } + } + + + private suspend fun verifyOrganizationAuditPagination( + store: PostgresqlIdentityStore, + organizationId: OrganizationId + ) { + val otherOrganizationId = IdentityFixtures.organizationId("organization-bootstrap") + suspend fun append(id: String, scope: OrganizationId, offset: Long) { + val event = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId(id), + AuditAction.ORGANIZATION_CHANGED, + scope.value + ).copy( + organizationId = scope, + target = AuditTarget(AuditTargetType.ORGANIZATION, scope.value), + occurredAt = IdentityFixtures.instant(offset) + ) + assertEquals(event, assertIs>(store.appendAuditEvent(event)).value) + } + append("audit-pg-page-3000", organizationId, 3_000) + append("audit-pg-page-2000-b", organizationId, 2_000) + append("audit-pg-page-2000-a", organizationId, 2_000) + append("audit-pg-page-1000", organizationId, 1_000) + append("audit-pg-page-other", otherOrganizationId, 4_000) + + val first = assertIs>( + store.listAuditEventsForOrganization( + OrganizationAuditEventPageRequest(organizationId, limit = 2) + ) + ).value + assertEquals( + listOf( + IdentityFixtures.auditEventId("audit-pg-page-3000").value, + IdentityFixtures.auditEventId("audit-pg-page-2000-b").value + ), + first.events.map { it.id.value } + ) + assertEquals(first.events.last().toOrganizationAuditCursor(), first.nextCursor) + + append("audit-pg-page-4000-new", organizationId, 4_000) + val second = assertIs>( + store.listAuditEventsForOrganization( + OrganizationAuditEventPageRequest(organizationId, cursor = first.nextCursor, limit = 2) + ) + ).value + assertEquals( + listOf( + IdentityFixtures.auditEventId("audit-pg-page-2000-a").value, + IdentityFixtures.auditEventId("audit-pg-page-1000").value + ), + second.events.map { it.id.value } + ) + assertEquals(null, second.nextCursor) + } + + private suspend fun verifyConcurrentBootstrap( + store: PostgresqlIdentityStore, + client: SqlClient + ): User = coroutineScope { + val user = IdentityFixtures.user() + val organization = IdentityFixtures.organization( + id = IdentityFixtures.organizationId("organization-bootstrap"), + slug = "bootstrap-org" + ) + val owner = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("membership-bootstrap-owner"), + organizationId = organization.id, + userId = user.id + ) + val enrollmentSession = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-bootstrap-enrollment"), + userId = user.id, + assurance = AuthenticationAssurance.RECOVERY, + authenticationMethod = SessionAuthenticationMethod.BOOTSTRAP + ) + val command = BootstrapIdentityCommand( + bootstrapSecretDigest = SecretDigest( + algorithm = DigestAlgorithm.SHA256, + encoded = "bootstrap-secret-receipt" + ), + user = user, + organization = organization, + ownerMembership = owner, + enrollmentSession = enrollmentSession, + auditEvent = IdentityFixtures.auditEvent( + id = IdentityFixtures.auditEventId("audit-identity-bootstrap"), + action = AuditAction.IDENTITY_BOOTSTRAPPED, + targetId = user.id.value + ).copy(organizationId = organization.id) + ) + val race = listOf( + async(Dispatchers.Default) { store.bootstrapIdentity(command) }, + async(Dispatchers.Default) { store.bootstrapIdentity(command) } + ).awaitAll() + val commit = assertIs>( + race.single { it is StoreResult.Success } + ).value + assertEquals(user, commit.user) + assertEquals(organization, commit.organization) + assertEquals(owner, commit.ownerMembership) + assertEquals(enrollmentSession, commit.enrollmentSession) + assertEquals( + IdentityStoreErrorCode.ALREADY_EXISTS, + assertIs(race.single { it is StoreResult.Failure }).error.code + ) + assertEquals( + 1L, + client.query("SELECT COUNT(*) AS count FROM aether_identity.bootstrap_receipts") + .execute().coAwait().iterator().next().getLong("count") + ) + assertEquals(user, assertIs>(store.findUser(user.id)).value) + assertEquals( + enrollmentSession, + assertIs>( + store.findSession(enrollmentSession.id) + ).value + ) + assertEquals( + owner, + assertIs>( + store.findMembershipForUser(user.id, organization.id) + ).value + ) + user + } + + private suspend fun verifyFederationJitRaceLeavesNoOrphans(store: PostgresqlIdentityStore) = coroutineScope { + val organizationId = IdentityFixtures.organizationId("organization-bootstrap") + val kind = FederationProviderKind.OIDC + val providerId = "jit-race" + val storageKey = IdentityFixtures.federationProviderStorageKey(kind, providerId) + val at = IdentityFixtures.instant(25_000) + val lease = assertIs>( + store.acquireFederationProviderLease( + AcquireFederationProviderLeaseCommand( + organizationId, + kind, + providerId, + storageKey, + at + ) + ) + ).value + val subject = ExternalSubject("postgresql-jit-race-subject") + val commands = (1..2).map { contender -> + val user = IdentityFixtures.user(IdentityFixtures.userId("postgresql-jit-race-$contender")).copy( + primaryEmail = null, + createdAt = at, + updatedAt = at, + activatedAt = at + ) + val membership = IdentityFixtures.membership( + id = IdentityFixtures.membershipId("postgresql-jit-race-$contender"), + organizationId = organizationId, + userId = user.id, + role = OrganizationRole.VIEWER + ).copy(createdAt = at, updatedAt = at) + val identity = IdentityFixtures.externalIdentity( + id = IdentityFixtures.externalIdentityId("postgresql-jit-race-$contender"), + userId = user.id, + provider = storageKey, + subject = subject + ).copy(createdAt = at, updatedAt = at) + LinkExternalIdentityCommand( + identity = identity, + replayReceipt = IdentityFixtures.replayReceipt( + id = IdentityFixtures.replayReceiptId("postgresql-jit-race-$contender"), + provider = storageKey + ).copy(receivedAt = at, expiresAt = IdentityFixtures.instant(625_000)), + federationProviderLease = lease, + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("postgresql-jit-race-$contender"), + AuditAction.EXTERNAL_IDENTITY_LINKED, + identity.id.value + ).copy( + organizationId = organizationId, + target = AuditTarget(AuditTargetType.EXTERNAL_IDENTITY, identity.id.value), + occurredAt = at + ), + jitProvisioning = FederationJitProvisioning(user, membership) + ) + } + val results = commands.map { command -> + async(Dispatchers.Default) { store.linkExternalIdentity(command) } + }.awaitAll() + val winnerIndex = results.indexOfFirst { it is StoreResult.Success } + val loserIndex = results.indexOfFirst { it is StoreResult.Failure } + assertTrue(winnerIndex >= 0) + assertTrue(loserIndex >= 0) + assertEquals(1, results.count { it is StoreResult.Success }) + assertEquals( + IdentityStoreErrorCode.UNIQUE_CONSTRAINT, + assertIs(results[loserIndex]).error.code + ) + val winner = commands[winnerIndex].jitProvisioning!! + val loser = commands[loserIndex].jitProvisioning!! + assertEquals( + winner.user, + assertIs>(store.findUser(winner.user.id)).value + ) + assertEquals( + winner.membership, + assertIs>(store.findMembership(winner.membership.id)).value + ) + assertEquals(null, assertIs>(store.findUser(loser.user.id)).value) + assertEquals( + null, + assertIs>(store.findMembership(loser.membership.id)).value + ) + } + + private suspend fun verifyIdentitySessionTouch( + store: PostgresqlIdentityStore, + client: SqlClient, + user: User + ) { + val createdAt = IdentityFixtures.instant(100_000) + val session = IdentityFixtures.session( + id = IdentityFixtures.sessionId("session-idle-touch"), + userId = user.id, + userSessionEpoch = user.sessionEpoch, + createdAt = createdAt + ) + assertEquals( + session, + assertIs>( + store.createSession( + CreateSessionCommand( + session, + IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-session-idle-touch-create"), + AuditAction.SESSION_CREATED, + session.id.value + ).copy(occurredAt = createdAt) + ) + ) + ).value + ) + val auditCountBeforeTouches = client.query( + "SELECT COUNT(*) AS count FROM aether_identity.audit_events" + ).execute().coAwait().single().getLong("count") + val touchedAt = IdentityFixtures.instant(160_000) + val extendedIdleExpiresAt = IdentityFixtures.instant(3_760_000) + val command = TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = session.version, + lastUsedAt = touchedAt, + idleExpiresAt = extendedIdleExpiresAt + ) + val raced = coroutineScope { + listOf( + async(Dispatchers.Default) { store.touchIdentitySession(command) }, + async(Dispatchers.Default) { store.touchIdentitySession(command) } + ).awaitAll() + } + val touched = assertIs>( + raced.single { it is StoreResult.Success } + ).value + assertEquals( + session.copy( + version = 1, + lastUsedAt = touchedAt, + idleExpiresAt = extendedIdleExpiresAt + ), + touched + ) + assertEquals( + IdentityStoreErrorCode.VERSION_CONFLICT, + assertIs(raced.single { it is StoreResult.Failure }).error.code + ) + + val row = client.preparedQuery( + "SELECT version, " + + "idle_expires_at = \$2::text::timestamptz AS idle_matches, " + + "(document->>'lastUsedAt')::timestamptz = \$3::text::timestamptz AS last_used_matches, " + + "(document->>'idleExpiresAt')::timestamptz = \$2::text::timestamptz AS document_idle_matches " + + "FROM aether_identity.sessions WHERE id = \$1" + ).execute(Tuple.of(session.id.value, extendedIdleExpiresAt.toString(), touchedAt.toString())) + .coAwait().single() + assertEquals(1L, row.getLong("version")) + assertEquals(true, row.getBoolean("idle_matches")) + assertEquals(true, row.getBoolean("last_used_matches")) + assertEquals(true, row.getBoolean("document_idle_matches")) + + // Policy changes may shorten the next idle window; equal touch times are valid clock ties. + val shortenedIdleExpiresAt = IdentityFixtures.instant(1_960_000) + val shortened = assertIs>( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = touched.version, + lastUsedAt = touchedAt, + idleExpiresAt = shortenedIdleExpiresAt + ) + ) + ).value + assertEquals(2L, shortened.version) + assertEquals(touchedAt, shortened.lastUsedAt) + assertEquals(shortenedIdleExpiresAt, shortened.idleExpiresAt) + assertEquals( + auditCountBeforeTouches, + client.query("SELECT COUNT(*) AS count FROM aether_identity.audit_events") + .execute().coAwait().single().getLong("count") + ) + + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertIs( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = shortened.version, + lastUsedAt = createdAt, + idleExpiresAt = shortenedIdleExpiresAt + ) + ) + ).error.code + ) + assertEquals( + IdentityStoreErrorCode.INVALID_TRANSITION, + assertIs( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = shortened.version, + lastUsedAt = touchedAt, + idleExpiresAt = kotlin.time.Instant.fromEpochMilliseconds( + session.absoluteExpiresAt.toEpochMilliseconds() + 1 + ) + ) + ) + ).error.code + ) + assertEquals( + IdentityStoreErrorCode.SESSION_EXPIRED, + assertIs( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = shortened.version, + lastUsedAt = shortenedIdleExpiresAt, + idleExpiresAt = shortenedIdleExpiresAt + ) + ) + ).error.code + ) + + val revokedAt = kotlin.time.Instant.fromEpochMilliseconds(touchedAt.toEpochMilliseconds() + 1) + val revoked = assertIs>( + store.revokeSession( + RevokeSessionCommand( + sessionId = session.id, + expectedVersion = shortened.version, + revokedAt = revokedAt, + reasonCode = "test_cleanup", + auditEvent = IdentityFixtures.auditEvent( + IdentityFixtures.auditEventId("audit-session-idle-touch-revoke"), + AuditAction.SESSION_REVOKED, + session.id.value + ).copy(occurredAt = revokedAt) + ) + ) + ).value + assertEquals( + IdentityStoreErrorCode.SESSION_NOT_ACTIVE, + assertIs( + store.touchIdentitySession( + TouchIdentitySessionCommand( + sessionId = session.id, + expectedVersion = revoked.version, + lastUsedAt = touchedAt, + idleExpiresAt = shortenedIdleExpiresAt + ) + ) + ).error.code + ) + } +} + +private suspend fun provisionEnvironment( + client: SqlClient, + environment: IdentityEnvironment, + namespace: String +) { + client.preparedQuery( + "SELECT aether_identity.provision_environment(\$1, \$2)" + ).execute(Tuple.of(environment.wireName, namespace)).coAwait() +} diff --git a/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlMigrationRunnerTest.kt b/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlMigrationRunnerTest.kt new file mode 100644 index 0000000..5cdfe4a --- /dev/null +++ b/aether-auth-postgresql/src/jvmTest/kotlin/codes/yousef/aether/auth/postgresql/PostgresqlMigrationRunnerTest.kt @@ -0,0 +1,93 @@ +package codes.yousef.aether.auth.postgresql + +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class PostgresqlMigrationRunnerTest { + @Test + fun committedManifestMatchesTheCompletePackagedMigrationBundle() { + val migrations = assertNotNull(loadPackagedPostgresqlMigrations()) + + assertEquals((1..11).toList(), migrations.map(PostgresqlMigration::version)) + assertEquals(11, migrations.map(PostgresqlMigration::checksum).toSet().size) + assertEquals(PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES.toSet(), assertNotNull( + discoverPackagedMigrationResources() + )) + } + + @Test + fun bundleRejectsChangedOrMissingMigrationBytes() { + val fixture = migrationFixture() + val changedBytes = fixture.bytes.toMutableMap().apply { + val resource = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCE + put(resource, getValue(resource) + "-- unreviewed change\n".encodeToByteArray()) + } + assertNull(fixture.load(bytes = changedBytes)) + + val missingBytes = fixture.bytes - PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCE + assertNull(fixture.load(bytes = missingBytes)) + } + + @Test + fun bundleRejectsMissingOrExtraManifestEntries() { + val fixture = migrationFixture() + val reviewed = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES + assertNull(fixture.load(manifest = fixture.manifestFor(reviewed.dropLast(1)))) + + val extra = "/db/aether-identity/V012__unreviewed.sql" + val bytesWithExtra = fixture.bytes + (extra to "SELECT 'unreviewed';\n".encodeToByteArray()) + assertNull( + fixture.load( + bytes = bytesWithExtra, + manifest = fixture.manifestFor(reviewed + extra, bytesWithExtra), + inventory = reviewed.toSet() + extra + ) + ) + } + + @Test + fun bundleRejectsMissingOrExtraPackagedSqlResources() { + val fixture = migrationFixture() + val reviewed = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES + assertNull(fixture.load(inventory = reviewed.dropLast(1).toSet())) + assertNull(fixture.load(inventory = reviewed.toSet() + "/db/aether-identity/unreviewed.sql")) + } + + private fun migrationFixture(): MigrationFixture { + val bytes = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES.mapIndexed { index, resource -> + resource to "SELECT ${index + 1};\n".encodeToByteArray() + }.toMap() + return MigrationFixture(bytes) + } + + private data class MigrationFixture(val bytes: Map) { + private val reviewedResources = PostgresqlMigrationRunner.DEFAULT_MIGRATION_RESOURCES + + fun load( + bytes: Map = this.bytes, + manifest: ByteArray = manifestFor(reviewedResources), + inventory: Set = reviewedResources.toSet() + ): List? = loadPostgresqlMigrationBundle( + requestedResources = reviewedResources, + manifestBytes = manifest, + packagedMigrationResources = inventory, + readResource = bytes::get + ) + + fun manifestFor( + resources: List, + bytes: Map = this.bytes + ): ByteArray = resources.joinToString(separator = "\n", postfix = "\n") { resource -> + "${bytes.getValue(resource).sha256()} ${resource.substringAfterLast('/')}" + }.encodeToByteArray() + } +} + +private fun ByteArray.sha256(): String = MessageDigest.getInstance("SHA-256") + .digest(this) + .joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } diff --git a/aether-auth-saml/build.gradle.kts b/aether-auth-saml/build.gradle.kts new file mode 100644 index 0000000..be19174 --- /dev/null +++ b/aether-auth-saml/build.gradle.kts @@ -0,0 +1,25 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm { compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } + wasmJs { nodejs() } + wasmWasi { nodejs() } + sourceSets { + commonMain.dependencies { + api(project(":aether-auth")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.xmlutil.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(project(":aether-auth-testkit")) + } + } +} diff --git a/aether-auth-saml/gradle.lockfile b/aether-auth-saml/gradle.lockfile new file mode 100644 index 0000000..e251a53 --- /dev/null +++ b/aether-auth-saml/gradle.lockfile @@ -0,0 +1,97 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.fasterxml.jackson.core:jackson-core:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.github.pdvrieze.xmlutil:core-jvmcommon:0.91.3=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +io.github.pdvrieze.xmlutil:core-wasm-js:0.91.3=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +io.github.pdvrieze.xmlutil:core-wasm-wasi:0.91.3=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +io.github.pdvrieze.xmlutil:core:0.91.3=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +io.netty:netty-buffer:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +junit:junit:4.13.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-wasi:2.3.21=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit:2.3.21=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-wasi:2.3.21=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.26.1=wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.30.0-beta=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-wasi:1.10.2=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-wasi:1.10.2=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-wasi:0.7.1=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,wasmWasiMainImplementationDependenciesMetadata,wasmWasiTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/BoundedSamlXml.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/BoundedSamlXml.kt new file mode 100644 index 0000000..8bff744 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/BoundedSamlXml.kt @@ -0,0 +1,319 @@ +@file:OptIn(nl.adaptivity.xmlutil.ExperimentalXmlUtilApi::class) + +package codes.yousef.aether.auth.saml + +import nl.adaptivity.xmlutil.EventType +import nl.adaptivity.xmlutil.xmlStreaming + +internal data class SamlXmlName( + val namespaceUri: String, + val localName: String, + val prefix: String +) { + val qualifiedName: String get() = if (prefix.isEmpty()) localName else "$prefix:$localName" +} + +internal data class SamlXmlAttribute(val name: SamlXmlName, val value: String) + +internal sealed interface SamlXmlNode + +internal class SamlXmlText(val value: String) : SamlXmlNode + +internal class SamlXmlElement( + val name: SamlXmlName, + val attributes: List, + val namespaceDeclarations: Map, + val inScopeNamespaces: Map, + internal val mutableChildren: MutableList = mutableListOf() +) : SamlXmlNode { + val children: List get() = mutableChildren + + fun attribute(localName: String, namespaceUri: String = ""): String? = + attributes.singleOrNull { it.name.localName == localName && it.name.namespaceUri == namespaceUri }?.value + + fun directElements(namespaceUri: String, localName: String): List = + children.filterIsInstance().filter { + it.name.namespaceUri == namespaceUri && it.name.localName == localName + } + + fun singleDirectElement(namespaceUri: String, localName: String): SamlXmlElement = + directElements(namespaceUri, localName).singleOrNull() ?: samlAbort(SamlErrorCode.RESPONSE_INVALID) + + fun optionalDirectElement(namespaceUri: String, localName: String): SamlXmlElement? { + val matches = directElements(namespaceUri, localName) + if (matches.size > 1) samlAbort(SamlErrorCode.RESPONSE_INVALID) + return matches.singleOrNull() + } + + fun normalizedText(maximumCharacters: Int = 8_192): String { + require(maximumCharacters > 0) + if (children.any { it !is SamlXmlText }) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val value = children.filterIsInstance().joinToString("") { it.value }.trim() + if (value.isEmpty() || value.length > maximumCharacters) samlAbort(SamlErrorCode.RESPONSE_INVALID) + return value + } +} + +internal data class SamlXmlDocument( + val root: SamlXmlElement, + val elementsById: Map, + val elementCount: Int, + val textCharacters: Int +) + +internal data class SamlXmlLimits( + val maximumBytes: Int, + val maximumDepth: Int, + val maximumElements: Int, + val maximumAttributesPerElement: Int, + val maximumTextCharacters: Int, + val maximumAttributeCharacters: Int = 16_384 +) + +internal object BoundedSamlXml { + private const val XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + fun parse(bytes: ByteArray, limits: SamlXmlLimits): SamlXmlDocument { + if (bytes.isEmpty() || bytes.size > limits.maximumBytes || hasForbiddenMarkup(bytes)) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val xml = try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: Throwable) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + if (xml.length > limits.maximumBytes) samlAbort(SamlErrorCode.RESPONSE_INVALID) + + val reader = try { + // DTD/entity declarations were rejected above, so expanding the five predefined XML + // entities is safe and keeps ordinary escaped issuer/attribute values interoperable. + xmlStreaming.newReader(xml, expandEntities = true) + } catch (_: Throwable) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val stack = mutableListOf() + val ids = mutableMapOf() + var root: SamlXmlElement? = null + var elementCount = 0 + var textCharacters = 0 + var documentEnded = false + try { + while (reader.hasNext()) { + val event = try { + reader.next() + } catch (_: Throwable) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + when (event) { + EventType.START_DOCUMENT -> if (root != null || stack.isNotEmpty()) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + + EventType.START_ELEMENT -> { + if (documentEnded || stack.size + 1 > limits.maximumDepth) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + elementCount++ + val namespaceDeclarationCount = reader.namespaceDecls.size + if (elementCount > limits.maximumElements || + reader.attributeCount + namespaceDeclarationCount > limits.maximumAttributesPerElement + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val declarations = linkedMapOf() + reader.namespaceDecls.forEach { namespace -> + val prefix = namespace.prefix + val uri = namespace.namespaceURI + if (prefix.length > 128 || uri.length > 2_048 || declarations.put(prefix, uri) != null) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + if (prefix == "xml" && uri != XML_NAMESPACE) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val inScope = linkedMapOf("xml" to XML_NAMESPACE) + stack.lastOrNull()?.inScopeNamespaces?.let(inScope::putAll) + declarations.forEach { (prefix, uri) -> inScope[prefix] = uri } + + val name = SamlXmlName( + namespaceUri = reader.namespaceURI, + localName = reader.localName, + prefix = reader.prefix + ) + validateName(name, inScope) + val attributes = ArrayList(reader.attributeCount) + val expandedNames = mutableSetOf>() + var idValue: String? = null + repeat(reader.attributeCount) { index -> + val attributeName = SamlXmlName( + namespaceUri = reader.getAttributeNamespace(index), + localName = reader.getAttributeLocalName(index), + prefix = reader.getAttributePrefix(index) + ) + validateName(attributeName, inScope, attribute = true) + val value = reader.getAttributeValue(index) + if (value.length > limits.maximumAttributeCharacters || + !expandedNames.add(attributeName.namespaceUri to attributeName.localName) + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val attribute = SamlXmlAttribute(attributeName, value) + attributes += attribute + if (isIdAttribute(attributeName)) { + if (idValue != null || !isValidXmlId(value)) samlAbort(SamlErrorCode.RESPONSE_INVALID) + idValue = value + } + } + val element = SamlXmlElement(name, attributes, declarations, inScope) + idValue?.let { id -> + if (ids.put(id, element) != null) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + if (stack.isEmpty()) { + if (root != null) samlAbort(SamlErrorCode.RESPONSE_INVALID) + root = element + } else { + stack.last().mutableChildren += element + } + stack += element + } + + EventType.END_ELEMENT -> { + val current = stack.removeLastOrNull() ?: samlAbort(SamlErrorCode.RESPONSE_INVALID) + if (current.name.namespaceUri != reader.namespaceURI || + current.name.localName != reader.localName || current.name.prefix != reader.prefix + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + + EventType.TEXT, EventType.CDSECT, EventType.IGNORABLE_WHITESPACE -> { + val value = reader.text + textCharacters += value.length + if (textCharacters > limits.maximumTextCharacters) samlAbort(SamlErrorCode.RESPONSE_INVALID) + if (stack.isEmpty()) { + if (value.any { !it.isWhitespace() }) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } else if (value.isNotEmpty()) { + stack.last().mutableChildren += SamlXmlText(value) + } + } + + EventType.COMMENT -> Unit // Exclusive c14n without comments intentionally omits comments. + EventType.END_DOCUMENT -> { + if (stack.isNotEmpty() || root == null) samlAbort(SamlErrorCode.RESPONSE_INVALID) + documentEnded = true + } + + EventType.DOCDECL, EventType.ENTITY_REF, EventType.PROCESSING_INSTRUCTION, + EventType.ATTRIBUTE -> samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + } catch (abort: SamlAbort) { + throw abort + } catch (_: Throwable) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } finally { + try { + reader.close() + } catch (_: Throwable) { + // A parser close failure cannot make rejected input valid. + } + } + if (stack.isNotEmpty() || root == null || !documentEnded) samlAbort(SamlErrorCode.RESPONSE_INVALID) + return SamlXmlDocument(root, ids.toMap(), elementCount, textCharacters) + } + + private fun validateName(name: SamlXmlName, inScope: Map, attribute: Boolean = false) { + if (name.localName.isEmpty() || name.localName.length > 256 || name.prefix.length > 128 || + name.namespaceUri.length > 2_048 + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + if (name.prefix.isNotEmpty() && inScope[name.prefix] != name.namespaceUri) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + if (!attribute && name.prefix.isEmpty() && (inScope[""] ?: "") != name.namespaceUri) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + if (attribute && name.prefix.isEmpty() && name.namespaceUri.isNotEmpty()) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + + private fun isIdAttribute(name: SamlXmlName): Boolean = + (name.namespaceUri.isEmpty() && name.localName in setOf("ID", "Id", "id")) || + (name.namespaceUri == XML_NAMESPACE && name.localName == "id") + + private fun isValidXmlId(value: String): Boolean { + if (value.isEmpty() || value.length > 255 || value[0] != '_' && !value[0].isLetter()) return false + return value.drop(1).all { it.isLetterOrDigit() || it == '_' || it == '-' || it == '.' } + } + + private fun hasForbiddenMarkup(bytes: ByteArray): Boolean { + // XML declarations may vary in case, so scan ASCII without allocating another full document. + val patterns = listOf(" + outer@ for (start in 0..bytes.size - pattern.length) { + for (offset in pattern.indices) { + val actual = bytes[start + offset].toInt() and 0xff + val expected = pattern[offset].code + val folded = if (actual in 'a'.code..'z'.code) actual - 32 else actual + if (folded != expected) continue@outer + } + return@any true + } + false + } + } +} + +internal fun canonicalizeExclusive( + element: SamlXmlElement, + excludedElement: SamlXmlElement? = null +): ByteArray { + val output = StringBuilder() + appendCanonicalElement(element, excludedElement, emptyMap(), output) + return output.toString().encodeToByteArray() +} + +private fun appendCanonicalElement( + element: SamlXmlElement, + excludedElement: SamlXmlElement?, + renderedNamespaces: Map, + output: StringBuilder +) { + if (element === excludedElement) return + output.append('<').append(element.name.qualifiedName) + + val visiblyUsedPrefixes = linkedSetOf(element.name.prefix) + element.attributes.forEach { attribute -> + if (attribute.name.prefix.isNotEmpty() && attribute.name.prefix != "xml") { + visiblyUsedPrefixes += attribute.name.prefix + } + } + val declarations = visiblyUsedPrefixes.map { prefix -> + val uri = element.inScopeNamespaces[prefix] + ?: if (prefix.isEmpty()) "" else samlAbort(SamlErrorCode.SIGNATURE_INVALID) + prefix to uri + }.filter { (prefix, uri) -> renderedNamespaces[prefix] != uri } + .sortedBy { it.first } + val nextRendered = renderedNamespaces.toMutableMap() + declarations.forEach { (prefix, uri) -> + if (prefix.isEmpty()) output.append(" xmlns=\"") + else output.append(" xmlns:").append(prefix).append("=\"") + output.append(escapeXmlAttribute(uri)).append('"') + nextRendered[prefix] = uri + } + + element.attributes.sortedWith( + compareBy({ it.name.namespaceUri }, { it.name.localName }) + ).forEach { attribute -> + output.append(' ').append(attribute.name.qualifiedName).append("=\"") + .append(escapeXmlAttribute(attribute.value)).append('"') + } + output.append('>') + element.children.forEach { child -> + when (child) { + is SamlXmlElement -> appendCanonicalElement(child, excludedElement, nextRendered, output) + is SamlXmlText -> output.append(escapeXmlText(child.value)) + } + } + output.append("') +} diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlCodec.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlCodec.kt new file mode 100644 index 0000000..76b1e21 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlCodec.kt @@ -0,0 +1,169 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.IdentityCrypto + +internal object SamlBase64 { + private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + private val decodeTable = IntArray(128) { -1 }.also { table -> + ALPHABET.forEachIndexed { index, character -> table[character.code] = index } + } + + fun encode(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + val output = StringBuilder((bytes.size + 2) / 3 * 4) + var index = 0 + while (index < bytes.size) { + val first = bytes[index++].toInt() and 0xff + val second = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1 + val third = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1 + output.append(ALPHABET[first ushr 2]) + output.append(ALPHABET[((first and 3) shl 4) or if (second >= 0) second ushr 4 else 0]) + if (second < 0) { + output.append("==") + } else { + output.append(ALPHABET[((second and 15) shl 2) or if (third >= 0) third ushr 6 else 0]) + output.append(if (third < 0) '=' else ALPHABET[third and 63]) + } + } + return output.toString() + } + + fun decode(value: String, maximumBytes: Int): ByteArray { + require(maximumBytes >= 0) { "maximumBytes must not be negative" } + require(value.isNotEmpty() && value.length % 4 == 0) { "Invalid base64 length" } + require(value.none(Char::isWhitespace)) { "Base64 must not contain whitespace" } + val padding = when { + value.endsWith("==") -> 2 + value.endsWith('=') -> 1 + else -> 0 + } + require('=' !in value.dropLast(padding)) { "Invalid base64 padding" } + val outputSize = value.length / 4 * 3 - padding + require(outputSize in 1..maximumBytes) { "Decoded base64 value exceeds the configured limit" } + val output = ByteArray(outputSize) + var outputIndex = 0 + var inputIndex = 0 + while (inputIndex < value.length) { + val a = decode(value[inputIndex++]) + val b = decode(value[inputIndex++]) + val thirdCharacter = value[inputIndex++] + val fourthCharacter = value[inputIndex++] + val c = if (thirdCharacter == '=') 0 else decode(thirdCharacter) + val d = if (fourthCharacter == '=') 0 else decode(fourthCharacter) + if (outputIndex < output.size) output[outputIndex++] = ((a shl 2) or (b ushr 4)).toByte() + if (outputIndex < output.size) output[outputIndex++] = ((b shl 4) or (c ushr 2)).toByte() + if (outputIndex < output.size) output[outputIndex++] = ((c shl 6) or d).toByte() + } + require(outputIndex == output.size && encode(output) == value) { "Non-canonical base64 encoding" } + return output + } + + private fun decode(character: Char): Int { + require(character.code < decodeTable.size && decodeTable[character.code] >= 0) { + "Invalid base64 character" + } + return decodeTable[character.code] + } +} + +/** RFC 1951 raw DEFLATE using bounded stored blocks. Compression is optional for correctness. */ +internal fun rawDeflateStored(input: ByteArray): ByteArray { + val blockCount = if (input.isEmpty()) 1 else (input.size + 65_534) / 65_535 + val output = ByteArray(input.size + blockCount * 5) + var sourceOffset = 0 + var outputOffset = 0 + repeat(blockCount) { blockIndex -> + val length = minOf(65_535, input.size - sourceOffset).coerceAtLeast(0) + val finalBlock = blockIndex == blockCount - 1 + output[outputOffset++] = if (finalBlock) 0x01 else 0x00 + output[outputOffset++] = length.toByte() + output[outputOffset++] = (length ushr 8).toByte() + val inverse = length xor 0xffff + output[outputOffset++] = inverse.toByte() + output[outputOffset++] = (inverse ushr 8).toByte() + input.copyInto(output, outputOffset, sourceOffset, sourceOffset + length) + sourceOffset += length + outputOffset += length + } + return output +} + +internal fun percentEncode(value: String): String = percentEncode(value.encodeToByteArray()) + +internal fun percentEncode(bytes: ByteArray): String = buildString(bytes.size * 3) { + val hex = "0123456789ABCDEF" + bytes.forEach { byte -> + val value = byte.toInt() and 0xff + if (value in 'A'.code..'Z'.code || value in 'a'.code..'z'.code || + value in '0'.code..'9'.code || value == '-'.code || value == '.'.code || + value == '_'.code || value == '~'.code + ) { + append(value.toChar()) + } else { + append('%').append(hex[value ushr 4]).append(hex[value and 0x0f]) + } + } +} + +internal fun appendQuery(url: String, encodedQuery: String): String = + "$url${if ('?' in url) '&' else '?'}$encodedQuery" + +internal fun escapeXmlText(value: String): String = buildString(value.length) { + value.forEach { character -> + when (character) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '\r' -> append(" ") + else -> append(character) + } + } +} + +internal fun escapeXmlAttribute(value: String): String = buildString(value.length) { + value.forEach { character -> + when (character) { + '&' -> append("&") + '<' -> append("<") + '"' -> append(""") + '\t' -> append(" ") + '\n' -> append(" ") + '\r' -> append(" ") + else -> append(character) + } + } +} + +internal suspend fun samlProviderStorageKey(config: SamlProviderConfig, crypto: IdentityCrypto): String { + val canonical = lengthPrefixed( + config.tenantId.value.encodeToByteArray(), + config.providerId.encodeToByteArray(), + config.idpEntityId.encodeToByteArray() + ) + return try { + val digest = crypto.sha256(canonical) + try { + if (digest.size != 32) samlAbort(SamlErrorCode.STORE_UNAVAILABLE) + "saml.${Base64Url.encode(digest)}" + } finally { + digest.fill(0) + } + } finally { + canonical.fill(0) + } +} + +internal fun lengthPrefixed(vararg values: ByteArray): ByteArray { + val output = ByteArray(values.sumOf { 4 + it.size }) + var offset = 0 + values.forEach { value -> + output[offset++] = (value.size ushr 24).toByte() + output[offset++] = (value.size ushr 16).toByte() + output[offset++] = (value.size ushr 8).toByte() + output[offset++] = value.size.toByte() + value.copyInto(output, offset) + offset += value.size + } + return output +} diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlFailure.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlFailure.kt new file mode 100644 index 0000000..b6f9ef1 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlFailure.kt @@ -0,0 +1,5 @@ +package codes.yousef.aether.auth.saml + +internal class SamlAbort(val code: SamlErrorCode) : RuntimeException() + +internal fun samlAbort(code: SamlErrorCode): Nothing = throw SamlAbort(code) diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlFederationHttpMiddleware.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlFederationHttpMiddleware.kt new file mode 100644 index 0000000..9906eb9 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlFederationHttpMiddleware.kt @@ -0,0 +1,823 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.AuditRequestMetadata +import codes.yousef.aether.auth.IdentityAuditRedactor +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.DeviceMetadata +import codes.yousef.aether.auth.FederationCallbackStateConsumeResult +import codes.yousef.aether.auth.FederationCallbackStateStore +import codes.yousef.aether.auth.FederationCallbackStateWriteResult +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.FederatedIdentitySessionCreator +import codes.yousef.aether.auth.FederatedIdentitySessionRequest +import codes.yousef.aether.auth.IdentityConfig +import codes.yousef.aether.auth.IdentityErrorCode +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.IdentityOperationResult +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.SameSitePolicy +import codes.yousef.aether.auth.SessionId +import codes.yousef.aether.auth.identityContext +import codes.yousef.aether.core.Cookie +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.pipeline.Middleware +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val SAML_COOKIE_NAME = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}") +private val SAML_HEADER_NAME = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}") + +class SamlFederationProviderRegistration( + val provider: SamlFederationProvider, + allowedSsoRedirectEndpoints: Set, + val successRedirectUrl: String +) { + val allowedSsoRedirectEndpoints: Set = allowedSsoRedirectEndpoints.toSet() + + init { + require(this.allowedSsoRedirectEndpoints.isNotEmpty() && + this.allowedSsoRedirectEndpoints.size <= 8 + ) { "At least one bounded SAML SSO endpoint is required" } + this.allowedSsoRedirectEndpoints.forEach(::requireRedirectEndpoint) + requireSafeRedirect(successRedirectUrl) + } + + override fun toString(): String = + "SamlFederationProviderRegistration(tenant=${provider.configuredTenantId}, " + + "provider=${provider.configuredProviderId}, ssoEndpoints=, successRedirect=)" +} + +sealed interface SamlFederationProviderResolution { + data class Found(val registration: SamlFederationProviderRegistration) : SamlFederationProviderResolution + /** The exact route belongs to another installed federation adapter (for example OIDC). */ + data object NotOwned : SamlFederationProviderResolution + data object Missing : SamlFederationProviderResolution + data object Unavailable : SamlFederationProviderResolution +} + +fun interface SamlFederationProviderRegistry { + suspend fun resolve(tenantId: OrganizationId, providerId: String): SamlFederationProviderResolution +} + +/** Server-only SAML request correlation; RelayState and request IDs never enter the state cookie. */ +class SamlServerCallbackState internal constructor( + val providerLease: FederationProviderLease, + internal val authenticationState: SamlAuthenticationState, + val predecessorSessionId: SessionId?, + val expectedPredecessorVersion: Long?, + val expiresAt: Instant +) { + val tenantId: OrganizationId get() = providerLease.organizationId + val providerId: String get() = providerLease.providerId + + init { + require(providerLease.kind == FederationProviderKind.SAML && + authenticationState.providerLease == providerLease + ) { "SAML callback state requires one exact SAML provider lease" } + require(authenticationState.expiresAt == expiresAt) { + "SAML callback state expiry must match its authentication state" + } + require((predecessorSessionId == null) == (expectedPredecessorVersion == null)) { + "SAML predecessor selector and version must either both be present or both be absent" + } + require(expectedPredecessorVersion == null || expectedPredecessorVersion >= 0) { + "SAML predecessor version must not be negative" + } + } + + /** + * Supplies defensive copies to an application-owned authenticated-encryption boundary for a + * distributed server-side state store. The block must not return or log RelayState. + */ + suspend fun useForProtection( + block: suspend ( + providerLease: FederationProviderLease, + challengeId: codes.yousef.aether.auth.ChallengeId, + requestId: String, + relayState: ByteArray, + linkToUserId: codes.yousef.aether.auth.UserId?, + predecessorSessionId: SessionId?, + expectedPredecessorVersion: Long?, + expiresAt: Instant + ) -> T + ): T { + val relay = authenticationState.relayStateBytes() + return try { + block( + providerLease, + authenticationState.challengeId, + authenticationState.requestId, + relay, + authenticationState.linkToUserId, + predecessorSessionId, + expectedPredecessorVersion, + expiresAt + ) + } finally { + relay.fill(0) + } + } + + /** Zero callback material when a store evicts or expires this record without consuming it. */ + fun destroy() { authenticationState.destroy() } + + override fun toString(): String = + "SamlServerCallbackState(tenantId=$tenantId, providerId=$providerId, " + + "authenticationState=, " + + "predecessor=${if (predecessorSessionId == null) "none" else "present"}, expiresAt=$expiresAt)" + + companion object { + /** Restore only after application-owned authenticated decryption of server-side state. */ + fun restore( + providerLease: FederationProviderLease, + challengeId: codes.yousef.aether.auth.ChallengeId, + requestId: String, + relayState: ByteArray, + linkToUserId: codes.yousef.aether.auth.UserId?, + expiresAt: Instant, + predecessorSessionId: SessionId? = null, + expectedPredecessorVersion: Long? = null + ): SamlServerCallbackState = SamlServerCallbackState( + providerLease = providerLease, + authenticationState = SamlAuthenticationState( + challengeId, + requestId, + relayState, + linkToUserId, + providerLease, + expiresAt + ), + predecessorSessionId = predecessorSessionId, + expectedPredecessorVersion = expectedPredecessorVersion, + expiresAt = expiresAt + ) + } +} + +data class SamlFederationHttpConfig( + val stateCookieName: String = "__Host-aether_saml_state", + val csrfCookieName: String = "__Host-aether_csrf", + val requestIdHeader: String = "X-Request-ID", + val maximumBodyBytes: Int = 8_388_608, + val maximumEncodedResponseCharacters: Int = 4_194_304, + val csrfCookieLifetimeSeconds: Long = 300 +) { + init { + require(SAML_COOKIE_NAME.matches(stateCookieName) && stateCookieName.startsWith("__Host-")) { + "SAML state cookie must be a valid __Host- cookie" + } + require(SAML_COOKIE_NAME.matches(csrfCookieName) && csrfCookieName.startsWith("__Host-")) { + "SAML CSRF handoff cookie must be a valid __Host- cookie" + } + require(SAML_HEADER_NAME.matches(requestIdHeader)) { "Invalid request-ID header" } + require(maximumBodyBytes in 4_096..16_777_216) { "SAML form limit must be 4 KiB..16 MiB" } + require(maximumEncodedResponseCharacters in 4_096..8_388_608) { + "SAML response limit must be 4 KiB..8 MiB" + } + require(maximumEncodedResponseCharacters <= maximumBodyBytes) { + "SAML response limit must not exceed the form-body limit" + } + require(csrfCookieLifetimeSeconds in 30..600) { "SAML CSRF handoff lifetime must be 30..600 seconds" } + } +} + +@Serializable +enum class SamlFederationHttpErrorCode { + @SerialName("request_invalid") REQUEST_INVALID, + @SerialName("provider_not_found") PROVIDER_NOT_FOUND, + @SerialName("identity_response_invalid") IDENTITY_RESPONSE_INVALID, + @SerialName("service_unavailable") SERVICE_UNAVAILABLE +} + +@Serializable +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +data class SamlFederationHttpError( + val code: SamlFederationHttpErrorCode, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val message: String = code.genericMessage, + val requestId: String, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val retryable: Boolean = code.defaultRetryable +) { + init { + require(message == code.genericMessage) { "SAML federation errors must use the stable generic message" } + require(retryable == code.defaultRetryable) { "SAML federation retryability is fixed by error code" } + } +} + +internal val SamlFederationHttpErrorCode.genericMessage: String + get() = when (this) { + SamlFederationHttpErrorCode.REQUEST_INVALID -> "The federation request is invalid." + SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND -> "The identity provider was not found." + SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID -> "The identity response could not be accepted." + SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE -> "The identity service is temporarily unavailable." + } + +internal val SamlFederationHttpErrorCode.defaultRetryable: Boolean + get() = this == SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE + +/** Common-code HTTP-Redirect/POST SAML transport on the fixed federation route. */ +class SamlFederationHttpMiddleware( + private val runtime: IdentityRuntime, + private val identityConfig: IdentityConfig, + private val providers: SamlFederationProviderRegistry, + private val callbackStates: FederationCallbackStateStore, + private val sessions: FederatedIdentitySessionCreator, + private val config: SamlFederationHttpConfig = SamlFederationHttpConfig() +) { + private val auditRedactor = IdentityAuditRedactor(runtime, identityConfig) + fun asMiddleware(): Middleware = middleware@{ exchange, next -> + if (!isFederationPath(exchange.request.path)) { + next() + return@middleware + } + + val requestId = requestId(exchange) + secureResponse(exchange) + if (hasInvalidRequestId(exchange)) { + fail(exchange, 400, SamlFederationHttpErrorCode.REQUEST_INVALID, requestId) + return@middleware + } + try { + val route = parseRoute(exchange.request.path) + if (route == null) { + fail(exchange, 404, SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return@middleware + } + val resolution = try { + providers.resolve(route.tenantId, route.providerId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + SamlFederationProviderResolution.Unavailable + } + val registration = when (resolution) { + is SamlFederationProviderResolution.Found -> resolution.registration + SamlFederationProviderResolution.NotOwned -> { + next() + return@middleware + } + SamlFederationProviderResolution.Missing -> { + fail(exchange, 404, SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return@middleware + } + SamlFederationProviderResolution.Unavailable -> { + fail(exchange, 503, SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return@middleware + } + } + if (registration.provider.configuredTenantId != route.tenantId || + registration.provider.configuredProviderId != route.providerId + ) { + fail(exchange, 404, SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return@middleware + } + + when (route.action) { + FederationAction.START -> handleStart(exchange, registration, route, requestId) + FederationAction.CALLBACK -> handleCallback(exchange, registration, route, requestId) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + fail(exchange, 503, SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + } + } + + private suspend fun handleStart( + exchange: Exchange, + registration: SamlFederationProviderRegistration, + route: FederationRoute, + requestId: String + ) { + if (exchange.request.method != HttpMethod.GET) { + methodNotAllowed(exchange, requestId, "GET") + return + } + if (!exchange.request.query.isNullOrEmpty() || !hasNoBody(exchange)) { + fail(exchange, 400, SamlFederationHttpErrorCode.REQUEST_INVALID, requestId) + return + } + previousStateSelector(exchange)?.let { selector -> + try { + val stale = callbackStates.consume(selector) + if (stale is FederationCallbackStateConsumeResult.Consumed) stale.state.destroy() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + // Best-effort stale-state cleanup must not reveal store state. + } + } + + val started = registration.provider.beginAuthentication(SamlAuthenticationRequest()) + val value = when (started) { + is SamlResult.Success -> started.value + is SamlResult.Failure -> { + val disabled = started.error.code == SamlErrorCode.PROVIDER_DISABLED + fail( + exchange, + if (disabled) 404 else if (started.error.retryable) 503 else 400, + if (disabled) SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND + else if (started.error.retryable) SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE + else SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, + requestId + ) + return + } + } + if (!registration.allowsSsoRedirect(value.redirectUrl)) { + value.state.destroy() + fail(exchange, 503, SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + if (value.state.providerLease.organizationId != route.tenantId || + value.state.providerLease.providerId != route.providerId || + value.state.providerLease.kind != FederationProviderKind.SAML + ) { + value.state.destroy() + fail(exchange, 404, SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND, requestId) + return + } + val predecessor = exchange.identityContext.session + val state = SamlServerCallbackState( + providerLease = value.state.providerLease, + authenticationState = value.state, + predecessorSessionId = predecessor?.id, + expectedPredecessorVersion = predecessor?.version, + expiresAt = value.expiresAt + ) + val selector = storeState(state) + if (selector == null) { + state.destroy() + fail(exchange, 503, SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + val lifetime = (value.expiresAt.toEpochMilliseconds() - runtime.clock.now().toEpochMilliseconds()) / 1_000 + if (lifetime <= 0) { + callbackStates.consume(selector) + state.destroy() + fail(exchange, 400, SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + exchange.response.setCookie(stateCookie(selector, lifetime)) + redirect(exchange, value.redirectUrl, 302) + } + + private suspend fun handleCallback( + exchange: Exchange, + registration: SamlFederationProviderRegistration, + route: FederationRoute, + requestId: String + ) { + exchange.response.setCookie(clearStateCookie()) + if (exchange.request.method != HttpMethod.POST) { + methodNotAllowed(exchange, requestId, "POST") + return + } + val selector = stateSelector(exchange) + val form = decodeCallbackForm(exchange) + if (selector == null || form == null) { + fail(exchange, 400, SamlFederationHttpErrorCode.REQUEST_INVALID, requestId) + return + } + val stored = try { + callbackStates.consume(selector) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + FederationCallbackStateConsumeResult.Unavailable + } + val state = when (stored) { + is FederationCallbackStateConsumeResult.Consumed -> stored.state + FederationCallbackStateConsumeResult.Missing -> { + fail(exchange, 400, SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + FederationCallbackStateConsumeResult.Unavailable -> { + fail(exchange, 503, SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE, requestId) + return + } + } + try { + if (state.tenantId != route.tenantId || state.providerId != route.providerId || + state.expiresAt <= runtime.clock.now() + ) { + fail(exchange, 400, SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + + val audit = auditRequest(exchange, requestId) + val completed = try { + registration.provider.completeAuthentication( + SamlPostResponseRequest( + samlResponse = form.getValue("SAMLResponse"), + relayState = form.getValue("RelayState"), + state = state.authenticationState, + auditRequest = audit + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + SamlResult.Failure(SamlError(SamlErrorCode.STORE_UNAVAILABLE)) + } + val authenticated = when (completed) { + is SamlResult.Success -> completed.value + is SamlResult.Failure -> { + val disabled = completed.error.code == SamlErrorCode.PROVIDER_DISABLED + fail( + exchange, + if (disabled) 404 else if (completed.error.retryable) 503 else 400, + if (disabled) SamlFederationHttpErrorCode.PROVIDER_NOT_FOUND + else if (completed.error.retryable) SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE + else SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, + requestId + ) + return + } + } + if (authenticated.providerLease != state.providerLease || + authenticated.providerLease.organizationId != route.tenantId || + authenticated.providerLease.providerId != route.providerId || + authenticated.authenticationMethod != codes.yousef.aether.auth.SessionAuthenticationMethod.SAML || + authenticated.assurance != codes.yousef.aether.auth.AuthenticationAssurance.SESSION || + !authenticated.passkeyStepUpRequiredForSensitiveActions + ) { + fail(exchange, 400, SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + val currentSession = exchange.identityContext.session + if (state.predecessorSessionId != null && currentSession != null && + state.predecessorSessionId != currentSession.id + ) { + fail(exchange, 400, SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, requestId) + return + } + val predecessorSessionId = state.predecessorSessionId ?: currentSession?.id + val expectedPredecessorVersion = if (currentSession != null && currentSession.id == predecessorSessionId) { + currentSession.version + } else { + state.expectedPredecessorVersion + } + val session = sessions.create( + FederatedIdentitySessionRequest( + userId = authenticated.userId, + providerLease = authenticated.providerLease, + externalIdentityId = authenticated.externalIdentityId, + authenticationMethod = authenticated.authenticationMethod, + authenticatedAt = runtime.clock.now(), + device = DeviceMetadata(userAgent = singleSafeHeader(exchange, "User-Agent")?.take(2_048)), + predecessorSessionId = predecessorSessionId, + expectedPredecessorVersion = expectedPredecessorVersion, + auditRequest = audit + ) + ) + val issued = when (session) { + is IdentityOperationResult.Success -> session.value + is IdentityOperationResult.Failure -> { + val unavailable = session.code == IdentityErrorCode.SERVICE_UNAVAILABLE || session.code.retryable + fail( + exchange, + if (unavailable) 503 else 400, + if (unavailable) SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE + else SamlFederationHttpErrorCode.IDENTITY_RESPONSE_INVALID, + requestId + ) + return + } + } + exchange.response.setCookie(identitySessionCookie(issued.cookieValue())) + exchange.response.setCookie(csrfHandoffCookie(issued.csrfToken())) + redirect(exchange, registration.successRedirectUrl, 303) + } finally { + state.destroy() + } + } + + private suspend fun storeState(state: SamlServerCallbackState): String? { + repeat(MAX_SELECTOR_ATTEMPTS) { + val entropy = runtime.secureRandom.nextBytes(STATE_SELECTOR_BYTES) + if (entropy.size != STATE_SELECTOR_BYTES) { + entropy.fill(0) + return null + } + val selector = try { + Base64Url.encode(entropy) + } finally { + entropy.fill(0) + } + when (try { + callbackStates.store(selector, state) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + FederationCallbackStateWriteResult.Unavailable + }) { + FederationCallbackStateWriteResult.Stored -> return selector + FederationCallbackStateWriteResult.Conflict -> Unit + FederationCallbackStateWriteResult.Unavailable -> return null + } + } + return null + } + + private suspend fun hasNoBody(exchange: Exchange): Boolean { + if (exchange.request.headers.getAll("Transfer-Encoding").isNotEmpty()) return false + val lengths = exchange.request.headers.getAll("Content-Length") + if (lengths.size > 1 || lengths.singleOrNull()?.let { it != "0" } == true) return false + if (exchange.request.headers.getAll("Content-Type").isNotEmpty()) return false + val bytes = exchange.request.bodyBytes() + return try { + bytes.isEmpty() + } finally { + bytes.fill(0) + } + } + + private suspend fun decodeCallbackForm(exchange: Exchange): Map? { + if (!hasFormContentType(exchange)) return null + if (exchange.request.headers.getAll("Transfer-Encoding").isNotEmpty()) return null + val lengthValues = exchange.request.headers.getAll("Content-Length") + if (lengthValues.size > 1) return null + val declaredLength = lengthValues.singleOrNull()?.let { value -> + if (value.isEmpty() || value.length > 20 || value.any { !it.isDigit() }) return null + value.toLongOrNull() ?: return null + } + if (declaredLength != null && (declaredLength <= 0 || declaredLength > config.maximumBodyBytes)) return null + val bytes = exchange.request.bodyBytes() + if (bytes.isEmpty() || bytes.size > config.maximumBodyBytes || + (declaredLength != null && declaredLength != bytes.size.toLong()) + ) { + bytes.fill(0) + return null + } + return try { + parseCallbackForm(bytes.decodeToString(throwOnInvalidSequence = true)) + } catch (_: IllegalArgumentException) { + null + } finally { + bytes.fill(0) + } + } + + private fun parseCallbackForm(encoded: String): Map? { + val fields = encoded.split('&') + if (fields.size != 2) return null + val result = linkedMapOf() + for (field in fields) { + val separator = field.indexOf('=') + if (separator <= 0) return null + val name = decodeFormComponent(field.substring(0, separator)) + if (name !in CALLBACK_FORM_FIELDS || result.containsKey(name)) return null + val value = decodeFormComponent(field.substring(separator + 1)) + if (value.any(::isProtocolControl)) return null + result[name] = value + } + val samlResponse = result["SAMLResponse"] ?: return null + val relayState = result["RelayState"] ?: return null + if (samlResponse.isEmpty() || samlResponse.length > config.maximumEncodedResponseCharacters || + relayState.length !in 16..80 || relayState.any(Char::isWhitespace) + ) return null + return result + } + + private fun hasFormContentType(exchange: Exchange): Boolean { + val values = exchange.request.headers.getAll("Content-Type") + if (values.size != 1) return false + val value = values.single() + if (value.length > 256 || value.any(::isProtocolControl)) return false + val parts = value.split(';').map(String::trim) + if (!parts.first().equals("application/x-www-form-urlencoded", ignoreCase = true)) return false + return parts.drop(1).all { it.equals("charset=utf-8", ignoreCase = true) } + } + + private fun stateSelector(exchange: Exchange): String? { + if (exchange.request.headers.getAll("Cookie").size > 1) return null + val fromParsed = exchange.request.cookies[config.stateCookieName]?.value + val raw = exchange.request.headers["Cookie"] ?: return fromParsed?.takeIf(STATE_SELECTOR::matches) + if (raw.length > MAXIMUM_COOKIE_HEADER || raw.any(::isProtocolControl)) return null + val parts = raw.split(';') + if (parts.size > MAXIMUM_COOKIE_FIELDS) return null + val matches = parts.map(String::trim).mapNotNull { item -> + val separator = item.indexOf('=') + if (separator <= 0 || item.substring(0, separator) != config.stateCookieName) null + else item.substring(separator + 1) + } + if (matches.size != 1 || (fromParsed != null && fromParsed != matches.single())) return null + return matches.single().takeIf(STATE_SELECTOR::matches) + } + + private fun previousStateSelector(exchange: Exchange): String? = stateSelector(exchange) + + private fun requestId(exchange: Exchange): String { + exchange.request.headers.getAll(config.requestIdHeader) + .singleOrNull() + ?.takeIf(REQUEST_ID::matches) + ?.let { return it } + val bytes = runtime.secureRandom.nextBytes(REQUEST_ID_BYTES) + return try { + if (bytes.size == REQUEST_ID_BYTES) "req_${Base64Url.encode(bytes)}" else "req_unavailable" + } finally { + bytes.fill(0) + } + } + + private fun hasInvalidRequestId(exchange: Exchange): Boolean { + val values = exchange.request.headers.getAll(config.requestIdHeader) + return values.size > 1 || values.singleOrNull()?.let { !REQUEST_ID.matches(it) } == true + } + + private suspend fun auditRequest(exchange: Exchange, requestId: String): AuditRequestMetadata = + AuditRequestMetadata( + requestId = requestId, + method = exchange.request.method.name, + path = exchange.request.path.take(4_096), + userAgent = auditRedactor.userAgent(singleSafeHeader(exchange, "User-Agent")) + ) + + private fun singleSafeHeader(exchange: Exchange, name: String): String? { + val values = exchange.request.headers.getAll(name) + if (values.size > 1) return null + return values.singleOrNull()?.takeIf { it.length <= 8_192 && it.none(::isProtocolControl) } + } + + private suspend fun methodNotAllowed(exchange: Exchange, requestId: String, allow: String) { + exchange.response.setHeader("Allow", allow) + fail(exchange, 405, SamlFederationHttpErrorCode.REQUEST_INVALID, requestId) + } + + private suspend fun fail( + exchange: Exchange, + status: Int, + code: SamlFederationHttpErrorCode, + requestId: String + ) { + if (exchange.response.statusCode in 300..599) return + secureResponse(exchange) + val error = SamlFederationHttpError(code = code, requestId = requestId) + exchange.response.statusCode = status + exchange.response.setHeader("Content-Type", "application/json; charset=utf-8") + exchange.response.write(ERROR_JSON.encodeToString(error)) + exchange.response.end() + } + + private suspend fun redirect(exchange: Exchange, location: String, status: Int) { + secureResponse(exchange) + exchange.response.statusCode = status + exchange.response.setHeader("Location", location) + exchange.response.end() + } + + private fun secureResponse(exchange: Exchange) { + exchange.response.setHeader("Cache-Control", "no-store") + exchange.response.setHeader("Pragma", "no-cache") + exchange.response.setHeader("Referrer-Policy", "no-referrer") + exchange.response.setHeader("X-Content-Type-Options", "nosniff") + } + + private fun stateCookie(value: String, maxAge: Long): Cookie = Cookie( + name = config.stateCookieName, + value = value, + path = "/", + maxAge = maxAge.coerceAtMost(900), + secure = true, + httpOnly = true, + // SAML HTTP-POST is cross-site. Explicit Lax cookies are omitted by browsers here; this + // short-lived selector is not session authority and must use None to preserve login-CSRF + // binding. The resulting identity session cookie remains Lax. + sameSite = Cookie.SameSite.NONE + ) + + private fun clearStateCookie(): Cookie = stateCookie("", 0) + + private fun identitySessionCookie(value: String): Cookie = Cookie( + name = identityConfig.cookie.name, + value = value, + path = identityConfig.cookie.path, + domain = identityConfig.cookie.domain, + secure = identityConfig.cookie.secure, + httpOnly = true, + sameSite = identityConfig.cookie.sameSite.toCoreSameSite() + ) + + private fun csrfHandoffCookie(value: String): Cookie = Cookie( + name = config.csrfCookieName, + value = value, + path = "/", + maxAge = config.csrfCookieLifetimeSeconds, + secure = true, + httpOnly = false, + sameSite = Cookie.SameSite.LAX + ) + + private fun parseRoute(path: String): FederationRoute? { + if (path.length > 2_048 || '?' in path || '#' in path || !path.startsWith("$FEDERATION_BASE/")) return null + val segments = path.removePrefix("$FEDERATION_BASE/").split('/') + if (segments.size != 3 || segments.any(String::isEmpty)) return null + val tenant = OrganizationId.parseOrNull(segments[0]) ?: return null + if (!PROVIDER_ID.matches(segments[1])) return null + val action = when (segments[2]) { + "start" -> FederationAction.START + "callback" -> FederationAction.CALLBACK + else -> return null + } + return FederationRoute(tenant, segments[1], action) + } + + private fun isFederationPath(path: String): Boolean = + path == FEDERATION_BASE || path.startsWith("$FEDERATION_BASE/") + + private data class FederationRoute( + val tenantId: OrganizationId, + val providerId: String, + val action: FederationAction + ) + + private enum class FederationAction { START, CALLBACK } + + private companion object { + const val FEDERATION_BASE = "/identity/v1/federation" + const val STATE_SELECTOR_BYTES = 32 + const val REQUEST_ID_BYTES = 12 + const val MAX_SELECTOR_ATTEMPTS = 3 + const val MAXIMUM_COOKIE_HEADER = 8_192 + const val MAXIMUM_COOKIE_FIELDS = 64 + val CALLBACK_FORM_FIELDS = setOf("SAMLResponse", "RelayState") + val PROVIDER_ID = Regex("[a-z0-9][a-z0-9_-]{0,62}") + val STATE_SELECTOR = Regex("[A-Za-z0-9_-]{43}") + val REQUEST_ID = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,254}") + val ERROR_JSON = Json { encodeDefaults = true; explicitNulls = false } + } +} + +private fun SamlFederationProviderRegistration.allowsSsoRedirect(value: String): Boolean { + if (value.length > 262_144 || '#' in value || isProtocolControlIn(value)) return false + return allowedSsoRedirectEndpoints.any { endpoint -> + value.startsWith("$endpoint?") && value.length > endpoint.length + 1 + } +} + +private fun requireRedirectEndpoint(value: String) { + require(value.length in 8..4_096 && '?' !in value && '#' !in value && !isProtocolControlIn(value)) { + "Invalid SAML SSO endpoint" + } + IdentityHttpRequest(IdentityHttpMethod.GET, value) +} + +private fun requireSafeRedirect(value: String) { + require(value.length in 8..4_096 && '#' !in value && !isProtocolControlIn(value)) { + "Invalid SAML success redirect" + } + IdentityHttpRequest(IdentityHttpMethod.GET, value) +} + +private fun decodeFormComponent(value: String): String { + val output = ByteArray(value.length) + var inputIndex = 0 + var outputIndex = 0 + while (inputIndex < value.length) { + when (val character = value[inputIndex]) { + '%' -> { + require(inputIndex + 2 < value.length) + val high = value[inputIndex + 1].digitToIntOrNull(16) ?: throw IllegalArgumentException() + val low = value[inputIndex + 2].digitToIntOrNull(16) ?: throw IllegalArgumentException() + output[outputIndex++] = ((high shl 4) or low).toByte() + inputIndex += 3 + } + '+' -> { + output[outputIndex++] = ' '.code.toByte() + inputIndex += 1 + } + else -> { + require(character.code in 0..0x7f) { "Non-ASCII form input must be percent encoded" } + output[outputIndex++] = character.code.toByte() + inputIndex += 1 + } + } + } + val exact = output.copyOf(outputIndex) + return try { + exact.decodeToString(throwOnInvalidSequence = true) + } finally { + exact.fill(0) + output.fill(0) + } +} + +private fun isProtocolControl(character: Char): Boolean = character.code < 0x20 || character.code == 0x7f +private fun isProtocolControlIn(value: String): Boolean = value.any(::isProtocolControl) + +private fun SameSitePolicy.toCoreSameSite(): Cookie.SameSite = when (this) { + SameSitePolicy.STRICT -> Cookie.SameSite.STRICT + SameSitePolicy.LAX -> Cookie.SameSite.LAX + SameSitePolicy.NONE -> Cookie.SameSite.NONE +} diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlIdentityProvider.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlIdentityProvider.kt new file mode 100644 index 0000000..c7fc2d5 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlIdentityProvider.kt @@ -0,0 +1,576 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.AuditAction +import codes.yousef.aether.auth.AuditActor +import codes.yousef.aether.auth.AuditActorType +import codes.yousef.aether.auth.AuditEvent +import codes.yousef.aether.auth.AuditOutcome +import codes.yousef.aether.auth.AuditTarget +import codes.yousef.aether.auth.AuditTargetType +import codes.yousef.aether.auth.AcquireFederationProviderLeaseCommand +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.Challenge +import codes.yousef.aether.auth.ChallengePurpose +import codes.yousef.aether.auth.ChallengeState +import codes.yousef.aether.auth.ConsumeChallengeCommand +import codes.yousef.aether.auth.CreateChallengeCommand +import codes.yousef.aether.auth.DigestAlgorithm +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.ExternalIdentity +import codes.yousef.aether.auth.ExternalIdentityReplayReceipt +import codes.yousef.aether.auth.ExternalIdentityState +import codes.yousef.aether.auth.FederationJitProvisioning +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.IdentityIdFactory +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.IdentityStore +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.LinkExternalIdentityCommand +import codes.yousef.aether.auth.Membership +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.RecordExternalIdentityReplayCommand +import codes.yousef.aether.auth.SecretDigest +import codes.yousef.aether.auth.StoreResult +import codes.yousef.aether.auth.User +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.UserState +import kotlin.coroutines.cancellation.CancellationException + +/** Tenant-scoped SAML 2.0 SP adapter with an HTTP-Redirect start and HTTP-POST callback. */ +class SamlIdentityProvider( + private val config: SamlProviderConfig, + private val runtime: IdentityRuntime, + private val store: IdentityStore, + private val metadataResolver: SamlMetadataResolver, + private val redirectSigner: SamlRedirectSigner? = null +) : SamlFederationProvider { + override val configuredTenantId get() = config.tenantId + override val configuredProviderId get() = config.providerId + private val ids = IdentityIdFactory(runtime) + private val validator = SamlResponseValidator(config, SamlSignatureVerifier(runtime.crypto)) + + override suspend fun beginAuthentication( + request: SamlAuthenticationRequest + ): SamlResult = runSaml { + requireConfiguredEnabled() + val now = runtime.clock.now() + val providerKey = samlProviderStorageKey(config, runtime.crypto) + val providerLease = acquireProviderLease(providerKey, now) + val metadata = resolveMetadata() + val challengeId = ids.newChallengeId() + val requestId = "_${challengeId.value}" + val relayStateBytes = runtime.secureRandom.nextBytes(32) + if (relayStateBytes.size != 32) { + relayStateBytes.fill(0) + samlAbort(SamlErrorCode.STORE_UNAVAILABLE) + } + try { + val relayState = Base64Url.encode(relayStateBytes) + val xml = buildAuthnRequest(requestId, metadata.redirectSsoUrl, now) + val xmlBytes = xml.encodeToByteArray() + val deflated = try { + rawDeflateStored(xmlBytes) + } finally { + xmlBytes.fill(0) + } + val encodedRequest = try { + SamlBase64.encode(deflated) + } finally { + deflated.fill(0) + } + var query = "SAMLRequest=${percentEncode(encodedRequest)}&RelayState=${percentEncode(relayState)}" + val signer = redirectSigner + if (metadata.wantAuthnRequestsSigned && signer == null) { + samlAbort(SamlErrorCode.PROVIDER_METADATA_INVALID) + } + if (signer != null) { + val keyId = signer.keyId + if (keyId.isBlank() || keyId.length > 255 || keyId.any(Char::isWhitespace)) { + samlAbort(SamlErrorCode.PROVIDER_METADATA_INVALID) + } + query += "&SigAlg=${percentEncode(signer.algorithm.uri)}" + val signedBytes = query.encodeToByteArray() + val signature = try { + signer.sign(signedBytes) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + samlAbort(SamlErrorCode.STORE_UNAVAILABLE) + } finally { + signedBytes.fill(0) + } + try { + val validSize = when (signer.algorithm) { + SamlSignatureAlgorithm.RSA_SHA256 -> signature.size in 256..1_024 + SamlSignatureAlgorithm.ECDSA_SHA256 -> signature.size == 64 + } + if (!validSize) samlAbort(SamlErrorCode.PROVIDER_METADATA_INVALID) + query += "&Signature=${percentEncode(SamlBase64.encode(signature))}" + } finally { + signature.fill(0) + } + } + val expiresAt = now + config.requestLifetime + val requestDigest = sha256Digest(requestId.encodeToByteArray()) + val bindingInput = lengthPrefixed(providerKey.encodeToByteArray(), relayStateBytes) + val bindingDigest = try { + sha256Digest(bindingInput) + } finally { + bindingInput.fill(0) + } + val challenge = Challenge( + id = challengeId, + purpose = ChallengePurpose.EXTERNAL_IDENTITY_LINK, + challengeDigest = requestDigest, + bindingDigest = bindingDigest, + userId = request.linkToUserId, + organizationId = config.tenantId, + federationProviderLease = providerLease, + createdAt = now, + expiresAt = expiresAt + ) + when (val created = store.createChallenge(CreateChallengeCommand(challenge))) { + is StoreResult.Success -> Unit + is StoreResult.Failure -> mapStoreFailure(created.error.code) + } + val state = SamlAuthenticationState( + challengeId, + requestId, + relayStateBytes, + request.linkToUserId, + providerLease, + expiresAt + ) + SamlAuthenticationStart( + redirectUrl = appendQuery(metadata.redirectSsoUrl, query), + state = state, + expiresAt = expiresAt + ) + } finally { + relayStateBytes.fill(0) + } + } + + override suspend fun completeAuthentication( + request: SamlPostResponseRequest + ): SamlResult = runSaml { + requireConfiguredEnabled() + val providerLease = requireCurrentProviderLease(request.state.providerLease) + val now = runtime.clock.now() + if (now >= request.state.expiresAt) samlAbort(SamlErrorCode.REQUEST_EXPIRED) + validateRelayState(request) + val challenge = loadChallenge(request.state.challengeId, now, providerLease) + validateChallengeBinding(challenge, request) + val metadata = resolveMetadata() + if (request.samlResponse.length > config.maximumEncodedResponseBytes * 2) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val xmlBytes = try { + SamlBase64.decode(request.samlResponse, config.maximumXmlBytes) + } catch (_: IllegalArgumentException) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val document = try { + BoundedSamlXml.parse( + xmlBytes, + SamlXmlLimits( + maximumBytes = config.maximumXmlBytes, + maximumDepth = config.maximumXmlDepth, + maximumElements = config.maximumElements, + maximumAttributesPerElement = config.maximumAttributesPerElement, + maximumTextCharacters = config.maximumTextCharacters + ) + ) + } finally { + xmlBytes.fill(0) + } + val validated = validator.validate(document, request.state.requestId, metadata, now) + when (val consumed = store.consumeChallenge( + ConsumeChallengeCommand( + challengeId = challenge.id, + expectedVersion = challenge.version, + terminalState = ChallengeState.CONSUMED, + consumedAt = now, + federationProviderLease = providerLease + ) + )) { + is StoreResult.Success -> Unit + is StoreResult.Failure -> when (consumed.error.code) { + IdentityStoreErrorCode.CHALLENGE_EXPIRED -> samlAbort(SamlErrorCode.REQUEST_EXPIRED) + IdentityStoreErrorCode.CHALLENGE_NOT_PENDING, + IdentityStoreErrorCode.VERSION_CONFLICT, + IdentityStoreErrorCode.INVALID_TRANSITION -> samlAbort(SamlErrorCode.REQUEST_INVALID) + else -> mapStoreFailure(consumed.error.code) + } + } + resolveIdentity(validated, request, challenge.userId, providerLease) + } + + private suspend fun resolveIdentity( + validated: ValidatedSamlResponse, + request: SamlPostResponseRequest, + linkToUserId: UserId?, + providerLease: FederationProviderLease + ): SamlAuthenticationResult { + val providerKey = providerLease.storageKey + val assertionBytes = canonicalizeExclusive(validated.assertion) + val assertionDigest = try { + runtime.crypto.sha256(assertionBytes) + } finally { + assertionBytes.fill(0) + } + if (assertionDigest.size != 32) { + assertionDigest.fill(0) + samlAbort(SamlErrorCode.STORE_UNAVAILABLE) + } + val now = runtime.clock.now() + val receipt = try { + ExternalIdentityReplayReceipt( + id = ids.newExternalReplayReceiptId(), + provider = providerKey, + assertionDigest = SecretDigest(DigestAlgorithm.SHA256, Base64Url.encode(assertionDigest)), + receivedAt = now, + expiresAt = maxOf(validated.claims.expiresAt + config.clockSkew, now + config.replayReceiptLifetime) + ) + } finally { + assertionDigest.fill(0) + } + val subject = validated.claims.subject + val existing = when (val found = store.findExternalIdentity(providerKey, subject)) { + is StoreResult.Success -> found.value + is StoreResult.Failure -> mapStoreFailure(found.error.code) + } + requireCurrentProviderLease(providerLease) + val identity = if (existing != null) { + if (existing.state != ExternalIdentityState.ACTIVE || + (linkToUserId != null && existing.userId != linkToUserId) + ) { + samlAbort(SamlErrorCode.EXTERNAL_IDENTITY_CONFLICT) + } + when (val replay = store.recordExternalIdentityReplay( + RecordExternalIdentityReplayCommand(receipt, providerLease) + )) { + is StoreResult.Success -> Unit + is StoreResult.Failure -> mapReplayFailure(replay.error.code) + } + existing + } else { + val jitProvisioning = if (linkToUserId == null) { + createJitProvisioning(validated.claims, now) + } else { + null + } + val userId = linkToUserId ?: requireNotNull(jitProvisioning).user.id + if (jitProvisioning == null) requireActiveUser(userId) + val created = ExternalIdentity( + id = ids.newExternalIdentityId(), + userId = userId, + provider = providerKey, + subject = subject, + email = findEmailAttribute(validated.claims), + createdAt = now, + updatedAt = now, + lastAuthenticatedAt = now + ) + val audit = AuditEvent( + id = ids.newAuditEventId(), + actor = if (linkToUserId == null) { + AuditActor(AuditActorType.SYSTEM) + } else { + AuditActor(AuditActorType.USER, userId = userId) + }, + organizationId = config.tenantId, + action = AuditAction.EXTERNAL_IDENTITY_LINKED, + target = AuditTarget(AuditTargetType.EXTERNAL_IDENTITY, created.id.value), + outcome = AuditOutcome.SUCCEEDED, + request = request.auditRequest, + occurredAt = now + ) + when (val linked = store.linkExternalIdentity( + LinkExternalIdentityCommand( + identity = created, + replayReceipt = receipt, + federationProviderLease = providerLease, + auditEvent = audit, + jitProvisioning = jitProvisioning + ) + )) { + is StoreResult.Success -> linked.value.identity + is StoreResult.Failure -> when (linked.error.code) { + IdentityStoreErrorCode.REPLAY_DETECTED -> samlAbort(SamlErrorCode.ASSERTION_REPLAYED) + IdentityStoreErrorCode.ALREADY_EXISTS, + IdentityStoreErrorCode.UNIQUE_CONSTRAINT -> samlAbort(SamlErrorCode.EXTERNAL_IDENTITY_CONFLICT) + else -> mapStoreFailure(linked.error.code) + } + } + } + requireActiveUser(identity.userId) + return SamlAuthenticationResult( + userId = identity.userId, + externalIdentityId = identity.id, + providerLease = providerLease, + claims = validated.claims + ) + } + + private fun createJitProvisioning( + claims: SamlVerifiedClaims, + occurredAt: kotlin.time.Instant + ): FederationJitProvisioning { + if (!config.jitProvisioningEnabled) samlAbort(SamlErrorCode.EXTERNAL_IDENTITY_NOT_LINKED) + val userId = ids.newUserId() + val user = User( + id = userId, + state = UserState.ACTIVE, + displayName = findDisplayNameAttribute(claims) ?: "Federated user", + primaryEmail = null, + createdAt = occurredAt, + updatedAt = occurredAt, + activatedAt = occurredAt + ) + val membership = Membership( + id = ids.newMembershipId(), + organizationId = config.tenantId, + userId = userId, + role = OrganizationRole.VIEWER, + state = MembershipState.ACTIVE, + createdAt = occurredAt, + updatedAt = occurredAt + ) + return FederationJitProvisioning(user, membership) + } + + private suspend fun requireActiveUser(userId: UserId) { + val user = when (val found = store.findUser(userId)) { + is StoreResult.Success -> found.value + is StoreResult.Failure -> mapStoreFailure(found.error.code) + } + if (user == null || user.state != UserState.ACTIVE) samlAbort(SamlErrorCode.EXTERNAL_IDENTITY_CONFLICT) + } + + private fun findEmailAttribute(claims: SamlVerifiedClaims): EmailAddress? { + val candidates = listOf( + "email", + "mail", + "urn:oid:0.9.2342.19200300.100.1.3", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + ) + val value = candidates.firstNotNullOfOrNull { claims.attributes[it]?.singleOrNull() } ?: return null + return try { + EmailAddress(value) + } catch (_: IllegalArgumentException) { + null + } + } + + private fun findDisplayNameAttribute(claims: SamlVerifiedClaims): String? { + val candidates = listOf( + "displayName", + "name", + "urn:oid:2.16.840.1.113730.3.1.241", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" + ) + return candidates.firstNotNullOfOrNull { claims.attributes[it]?.singleOrNull() } + ?.takeIf { it.isNotBlank() && it.length <= 200 } + } + + private suspend fun validateRelayState(request: SamlPostResponseRequest) { + val actual = try { + Base64Url.decode(request.relayState, maximumBytes = 64) + } catch (_: IllegalArgumentException) { + samlAbort(SamlErrorCode.REQUEST_INVALID) + } + val expected = request.state.relayStateBytes() + val matches = try { + actual.size == 32 && expected.size == 32 && runtime.crypto.constantTimeEquals(expected, actual) + } finally { + actual.fill(0) + expected.fill(0) + } + if (!matches) samlAbort(SamlErrorCode.REQUEST_INVALID) + } + + private suspend fun loadChallenge( + id: codes.yousef.aether.auth.ChallengeId, + now: kotlin.time.Instant, + expectedProviderLease: FederationProviderLease + ): Challenge { + val challenge = when (val found = store.findChallenge(id)) { + is StoreResult.Success -> found.value + is StoreResult.Failure -> mapStoreFailure(found.error.code) + } ?: samlAbort(SamlErrorCode.REQUEST_INVALID) + if (challenge.purpose != ChallengePurpose.EXTERNAL_IDENTITY_LINK || + challenge.organizationId != config.tenantId || challenge.state != ChallengeState.PENDING || + challenge.federationProviderLease != expectedProviderLease + ) { + samlAbort(SamlErrorCode.REQUEST_INVALID) + } + if (challenge.expiresAt <= now) samlAbort(SamlErrorCode.REQUEST_EXPIRED) + return challenge + } + + private suspend fun validateChallengeBinding(challenge: Challenge, request: SamlPostResponseRequest) { + if (request.state.requestId != "_${challenge.id.value}" || request.state.linkToUserId != challenge.userId) { + samlAbort(SamlErrorCode.REQUEST_INVALID) + } + val requestDigest = runtime.crypto.sha256(request.state.requestId.encodeToByteArray()) + val requestMatches = compareDigest(challenge.challengeDigest, requestDigest) + requestDigest.fill(0) + if (!requestMatches) samlAbort(SamlErrorCode.REQUEST_INVALID) + + val providerKey = request.state.providerLease.storageKey + val relay = request.state.relayStateBytes() + val bindingInput = lengthPrefixed(providerKey.encodeToByteArray(), relay) + relay.fill(0) + val bindingDigest = try { + runtime.crypto.sha256(bindingInput) + } finally { + bindingInput.fill(0) + } + val bindingMatches = compareDigest(challenge.bindingDigest, bindingDigest) + bindingDigest.fill(0) + if (!bindingMatches) samlAbort(SamlErrorCode.REQUEST_INVALID) + } + + private suspend fun sha256Digest(value: ByteArray): SecretDigest { + val digest = try { + runtime.crypto.sha256(value) + } finally { + value.fill(0) + } + return try { + SecretDigest(DigestAlgorithm.SHA256, Base64Url.encode(digest)) + } finally { + digest.fill(0) + } + } + + private suspend fun compareDigest(expected: SecretDigest, actual: ByteArray): Boolean { + if (expected.algorithm != DigestAlgorithm.SHA256 || expected.keyVersion != null) return false + val expectedBytes = try { + Base64Url.decode(expected.encoded, 32) + } catch (_: IllegalArgumentException) { + return false + } + return try { + expectedBytes.size == 32 && actual.size == 32 && runtime.crypto.constantTimeEquals(expectedBytes, actual) + } finally { + expectedBytes.fill(0) + } + } + + private suspend fun resolveMetadata(): SamlProviderMetadata { + val metadata = try { + metadataResolver.resolve() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + samlAbort(SamlErrorCode.PROVIDER_METADATA_INVALID) + } + val now = runtime.clock.now() + if (metadata.entityId != config.idpEntityId || metadata.validUntil <= now || + metadata.verificationKeys.none { key -> + (key.validFrom == null || now >= key.validFrom!!) && (key.validUntil == null || now < key.validUntil!!) + } + ) { + samlAbort(SamlErrorCode.PROVIDER_METADATA_INVALID) + } + return metadata + } + + private fun buildAuthnRequest(requestId: String, destination: String, issuedAt: kotlin.time.Instant): String = + buildString(1_024) { + append("") + append("").append(escapeXmlText(config.spEntityId)).append("") + append("") + append("") + } + + private fun requireConfiguredEnabled() { + if (!config.enabled) samlAbort(SamlErrorCode.PROVIDER_DISABLED) + } + + private suspend fun acquireProviderLease( + storageKey: String, + acquiredAt: kotlin.time.Instant + ): FederationProviderLease { + val command = AcquireFederationProviderLeaseCommand( + organizationId = config.tenantId, + kind = FederationProviderKind.SAML, + providerId = config.providerId, + storageKey = storageKey, + acquiredAt = acquiredAt + ) + return when (val acquired = store.acquireFederationProviderLease(command)) { + is StoreResult.Success -> acquired.value.takeIf { + it.organizationId == command.organizationId && + it.kind == command.kind && + it.providerId == command.providerId && + it.storageKey == command.storageKey + } ?: samlAbort(SamlErrorCode.PROVIDER_DISABLED) + is StoreResult.Failure -> mapProviderControlFailure(acquired.error.code) + } + } + + private suspend fun requireCurrentProviderLease( + lease: FederationProviderLease + ): FederationProviderLease { + val expectedStorageKey = samlProviderStorageKey(config, runtime.crypto) + if (lease.organizationId != config.tenantId || + lease.kind != FederationProviderKind.SAML || + lease.providerId != config.providerId || + lease.storageKey != expectedStorageKey + ) { + samlAbort(SamlErrorCode.PROVIDER_DISABLED) + } + return when (val validated = store.validateFederationProviderLease(lease)) { + is StoreResult.Success -> validated.value.takeIf { it == lease } + ?: samlAbort(SamlErrorCode.PROVIDER_DISABLED) + is StoreResult.Failure -> mapProviderControlFailure(validated.error.code) + } + } + + private fun mapProviderControlFailure(code: IdentityStoreErrorCode): Nothing = when (code) { + IdentityStoreErrorCode.UNAVAILABLE, + IdentityStoreErrorCode.INTERNAL, + IdentityStoreErrorCode.VERSION_CONFLICT -> samlAbort(SamlErrorCode.STORE_UNAVAILABLE) + else -> samlAbort(SamlErrorCode.PROVIDER_DISABLED) + } + + private fun mapReplayFailure(code: IdentityStoreErrorCode): Nothing = when (code) { + IdentityStoreErrorCode.REPLAY_DETECTED, + IdentityStoreErrorCode.ALREADY_EXISTS, + IdentityStoreErrorCode.UNIQUE_CONSTRAINT -> samlAbort(SamlErrorCode.ASSERTION_REPLAYED) + else -> mapStoreFailure(code) + } + + private fun mapStoreFailure(code: IdentityStoreErrorCode): Nothing = when (code) { + IdentityStoreErrorCode.UNAVAILABLE, + IdentityStoreErrorCode.INTERNAL, + IdentityStoreErrorCode.VERSION_CONFLICT -> samlAbort(SamlErrorCode.STORE_UNAVAILABLE) + IdentityStoreErrorCode.FEDERATION_PROVIDER_DISABLED -> samlAbort(SamlErrorCode.PROVIDER_DISABLED) + IdentityStoreErrorCode.REPLAY_DETECTED -> samlAbort(SamlErrorCode.ASSERTION_REPLAYED) + else -> samlAbort(SamlErrorCode.EXTERNAL_IDENTITY_CONFLICT) + } +} + +private suspend fun runSaml(block: suspend () -> T): SamlResult = try { + SamlResult.Success(block()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (abort: SamlAbort) { + SamlResult.Failure(SamlError(abort.code)) +} catch (_: IllegalArgumentException) { + SamlResult.Failure(SamlError(SamlErrorCode.RESPONSE_INVALID)) +} catch (_: Throwable) { + SamlResult.Failure(SamlError(SamlErrorCode.STORE_UNAVAILABLE)) +} diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlResponseValidator.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlResponseValidator.kt new file mode 100644 index 0000000..10d315c --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlResponseValidator.kt @@ -0,0 +1,279 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.ExternalSubject +import kotlin.time.Instant + +internal data class ValidatedSamlResponse( + val claims: SamlVerifiedClaims, + val assertion: SamlXmlElement, + val responseSignature: VerifiedSamlSignature?, + val assertionSignature: VerifiedSamlSignature? +) + +internal class SamlResponseValidator( + private val config: SamlProviderConfig, + private val signatureVerifier: SamlSignatureVerifier +) { + suspend fun validate( + document: SamlXmlDocument, + expectedRequestId: String, + metadata: SamlProviderMetadata, + now: Instant + ): ValidatedSamlResponse { + val response = document.root + if (response.name.namespaceUri != SAML_PROTOCOL_NAMESPACE || response.name.localName != "Response") { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + requireRootIdentity(document, response, "Response") + requireExactAttribute(response, "Version", "2.0") + requireExactAttribute(response, "Destination", config.assertionConsumerServiceUrl) + requireExactAttribute(response, "InResponseTo", expectedRequestId) + val responseIssuedAt = parseInstant(requireAttribute(response, "IssueInstant")) + validateFreshIssueInstant(responseIssuedAt, now) + requireIssuer(response) + requireSuccessStatus(response) + + val assertions = response.directElements(SAML_ASSERTION_NAMESPACE, "Assertion") + if (assertions.size != 1) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val assertion = assertions.single() + requireRootIdentity(document, assertion, "Assertion") + if (allDescendants(response).count { + it.name.namespaceUri == SAML_ASSERTION_NAMESPACE && it.name.localName == "Assertion" + } != 1 + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + + val responseSignatureElement = response.optionalDirectElement(XMLDSIG_NAMESPACE, "Signature") + val assertionSignatureElement = assertion.optionalDirectElement(XMLDSIG_NAMESPACE, "Signature") + val allowedSignatures = listOfNotNull(responseSignatureElement, assertionSignatureElement) + val everySignature = allDescendants(response).filter { + it.name.namespaceUri == XMLDSIG_NAMESPACE && it.name.localName == "Signature" + } + if (everySignature.size != allowedSignatures.size || everySignature.any { candidate -> + allowedSignatures.none { it === candidate } + } + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + requireSignaturePolicy(responseSignatureElement != null, assertionSignatureElement != null) + val responseSignature = responseSignatureElement?.let { + signatureVerifier.verify(document, response, it, metadata, now) + } + val assertionSignature = assertionSignatureElement?.let { + signatureVerifier.verify(document, assertion, it, metadata, now) + } + + requireExactAttribute(assertion, "Version", "2.0") + val issuedAt = parseInstant(requireAttribute(assertion, "IssueInstant")) + validateFreshIssueInstant(issuedAt, now) + requireIssuer(assertion) + + val subject = assertion.singleDirectElement(SAML_ASSERTION_NAMESPACE, "Subject") + val nameId = subject.singleDirectElement(SAML_ASSERTION_NAMESPACE, "NameID") + val subjectValue = strictText(nameId, 1_024) + val nameIdFormat = nameId.attribute("Format") + ?: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + if (nameIdFormat !in config.allowedNameIdFormats) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val subjectConfirmation = subject.singleDirectElement(SAML_ASSERTION_NAMESPACE, "SubjectConfirmation") + requireExactAttribute( + subjectConfirmation, + "Method", + "urn:oasis:names:tc:SAML:2.0:cm:bearer" + ) + val confirmationData = subjectConfirmation.singleDirectElement( + SAML_ASSERTION_NAMESPACE, + "SubjectConfirmationData" + ) + requireExactAttribute(confirmationData, "Recipient", config.assertionConsumerServiceUrl) + requireExactAttribute(confirmationData, "InResponseTo", expectedRequestId) + confirmationData.attribute("NotBefore")?.let { validateNotBefore(parseInstant(it), now) } + val confirmationExpiresAt = parseInstant(requireAttribute(confirmationData, "NotOnOrAfter")) + validateNotOnOrAfter(confirmationExpiresAt, now) + + val conditions = assertion.singleDirectElement(SAML_ASSERTION_NAMESPACE, "Conditions") + conditions.attribute("NotBefore")?.let { validateNotBefore(parseInstant(it), now) } + val conditionsExpiresAt = parseInstant(requireAttribute(conditions, "NotOnOrAfter")) + validateNotOnOrAfter(conditionsExpiresAt, now) + if (conditionsExpiresAt - issuedAt > config.maximumAssertionLifetime + config.clockSkew) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + validateAudiences(conditions) + + val authnStatement = assertion.singleDirectElement(SAML_ASSERTION_NAMESPACE, "AuthnStatement") + val authenticatedAt = parseInstant(requireAttribute(authnStatement, "AuthnInstant")) + if (authenticatedAt > now + config.clockSkew || authenticatedAt > issuedAt + config.clockSkew) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val sessionExpiresAt = authnStatement.attribute("SessionNotOnOrAfter")?.let { + parseInstant(it).also { instant -> validateNotOnOrAfter(instant, now) } + } + val sessionIndex = authnStatement.attribute("SessionIndex")?.also { + if (it.isBlank() || it.length > 1_024) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val authnContext = authnStatement.optionalDirectElement(SAML_ASSERTION_NAMESPACE, "AuthnContext") + ?.optionalDirectElement(SAML_ASSERTION_NAMESPACE, "AuthnContextClassRef") + ?.let { strictText(it, 1_024) } + + val expiresAt = listOfNotNull(confirmationExpiresAt, conditionsExpiresAt, sessionExpiresAt).min() + val attributes = parseAttributes(assertion) + return ValidatedSamlResponse( + claims = SamlVerifiedClaims( + issuer = config.idpEntityId, + subject = try { + ExternalSubject(subjectValue) + } catch (_: IllegalArgumentException) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + }, + nameIdFormat = nameIdFormat, + issuedAt = issuedAt, + authenticatedAt = authenticatedAt, + expiresAt = expiresAt, + sessionIndex = sessionIndex, + authenticationContext = authnContext, + attributes = attributes + ), + assertion = assertion, + responseSignature = responseSignature, + assertionSignature = assertionSignature + ) + } + + private fun requireRootIdentity(document: SamlXmlDocument, element: SamlXmlElement, localName: String) { + val id = requireAttribute(element, "ID") + if (document.elementsById[id] !== element || element.name.localName != localName) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + + private fun requireIssuer(parent: SamlXmlElement) { + val issuer = parent.singleDirectElement(SAML_ASSERTION_NAMESPACE, "Issuer") + if (strictText(issuer, 2_048) != config.idpEntityId) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + + private fun requireSuccessStatus(response: SamlXmlElement) { + val status = response.singleDirectElement(SAML_PROTOCOL_NAMESPACE, "Status") + val statusCode = status.singleDirectElement(SAML_PROTOCOL_NAMESPACE, "StatusCode") + requireExactAttribute( + statusCode, + "Value", + "urn:oasis:names:tc:SAML:2.0:status:Success" + ) + if (statusCode.directElements(SAML_PROTOCOL_NAMESPACE, "StatusCode").isNotEmpty()) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + + private fun validateAudiences(conditions: SamlXmlElement) { + val allowedConditionChildren = setOf("AudienceRestriction", "OneTimeUse") + if (conditions.children.filterIsInstance().any { + it.name.namespaceUri != SAML_ASSERTION_NAMESPACE || it.name.localName !in allowedConditionChildren + } + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + val restrictions = conditions.directElements(SAML_ASSERTION_NAMESPACE, "AudienceRestriction") + if (restrictions.isEmpty() || restrictions.size > 16) samlAbort(SamlErrorCode.RESPONSE_INVALID) + restrictions.forEach { restriction -> + val audiences = restriction.directElements(SAML_ASSERTION_NAMESPACE, "Audience") + if (audiences.isEmpty() || audiences.size > 32 || + audiences.map { strictText(it, 2_048) }.none { it == config.spEntityId } + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + } + + private fun parseAttributes(assertion: SamlXmlElement): Map> { + val statements = assertion.directElements(SAML_ASSERTION_NAMESPACE, "AttributeStatement") + if (statements.size > 16) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val attributes = linkedMapOf>() + var valueCount = 0 + statements.forEach { statement -> + val children = statement.children.filterIsInstance() + if (children.any { + it.name.namespaceUri != SAML_ASSERTION_NAMESPACE || it.name.localName != "Attribute" + } || children.size > 128 + ) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + children.forEach { attribute -> + val name = requireAttribute(attribute, "Name") + if (name.isBlank() || name.length > 1_024) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val values = attribute.directElements(SAML_ASSERTION_NAMESPACE, "AttributeValue") + if (values.isEmpty() || values.size > 128) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val destination = attributes.getOrPut(name) { mutableListOf() } + values.forEach { value -> + valueCount++ + if (valueCount > 512) samlAbort(SamlErrorCode.RESPONSE_INVALID) + destination += strictText(value, 4_096) + } + } + } + return attributes.mapValues { (_, values) -> values.toList() } + } + + private fun requireSignaturePolicy(responseSigned: Boolean, assertionSigned: Boolean) { + val valid = when (config.signaturePolicy) { + SamlSignaturePolicy.ASSERTION_OR_RESPONSE -> responseSigned || assertionSigned + SamlSignaturePolicy.ASSERTION -> assertionSigned + SamlSignaturePolicy.RESPONSE -> responseSigned + SamlSignaturePolicy.BOTH -> responseSigned && assertionSigned + } + if (!valid) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + + private fun requireAttribute(element: SamlXmlElement, name: String): String = + element.attribute(name)?.also { + if (it.isEmpty() || it.length > 4_096) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } ?: samlAbort(SamlErrorCode.RESPONSE_INVALID) + + private fun requireExactAttribute(element: SamlXmlElement, name: String, expected: String) { + if (requireAttribute(element, name) != expected) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + + private fun strictText(element: SamlXmlElement, maximumCharacters: Int): String { + if (element.children.any { it !is SamlXmlText }) samlAbort(SamlErrorCode.RESPONSE_INVALID) + val value = element.children.filterIsInstance().joinToString("") { it.value } + if (value.isEmpty() || value.length > maximumCharacters || value != value.trim()) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + return value + } + + private fun parseInstant(value: String): Instant { + if (!value.endsWith('Z') || value.length !in 20..40) samlAbort(SamlErrorCode.RESPONSE_INVALID) + return try { + Instant.parse(value) + } catch (_: IllegalArgumentException) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + + private fun validateFreshIssueInstant(value: Instant, now: Instant) { + if (value > now + config.clockSkew || now - value > config.maximumAssertionLifetime + config.clockSkew) { + samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + } + + private fun validateNotBefore(value: Instant, now: Instant) { + if (now + config.clockSkew < value) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + + private fun validateNotOnOrAfter(value: Instant, now: Instant) { + if (now - config.clockSkew >= value) samlAbort(SamlErrorCode.RESPONSE_INVALID) + } + + private fun allDescendants(root: SamlXmlElement): List { + val result = mutableListOf() + fun visit(element: SamlXmlElement) { + element.children.filterIsInstance().forEach { child -> + result += child + visit(child) + } + } + result += root + visit(root) + return result + } +} diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlSignatureVerifier.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlSignatureVerifier.kt new file mode 100644 index 0000000..49a9251 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlSignatureVerifier.kt @@ -0,0 +1,192 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.Es256Signature +import codes.yousef.aether.auth.IdentityCrypto +import codes.yousef.aether.auth.RsaSha256Signature +import kotlin.time.Instant + +internal const val SAML_PROTOCOL_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:protocol" +internal const val SAML_ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion" +internal const val XMLDSIG_NAMESPACE = "http://www.w3.org/2000/09/xmldsig#" +internal const val EXCLUSIVE_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#" +internal const val ENVELOPED_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#enveloped-signature" +internal const val SHA256_DIGEST = "http://www.w3.org/2001/04/xmlenc#sha256" + +internal data class VerifiedSamlSignature( + val keyId: String, + val algorithm: SamlSignatureAlgorithm +) + +internal class SamlSignatureVerifier(private val crypto: IdentityCrypto) { + suspend fun verify( + document: SamlXmlDocument, + target: SamlXmlElement, + signature: SamlXmlElement, + metadata: SamlProviderMetadata, + now: Instant + ): VerifiedSamlSignature { + if (signature.name.namespaceUri != XMLDSIG_NAMESPACE || signature.name.localName != "Signature" || + signature !in target.directElements(XMLDSIG_NAMESPACE, "Signature") + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val targetId = target.attribute("ID") ?: samlAbort(SamlErrorCode.SIGNATURE_INVALID) + if (document.elementsById[targetId] !== target) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + + val elementChildren = signature.children.filterIsInstance() + if (elementChildren.any { it.name.namespaceUri != XMLDSIG_NAMESPACE } || + elementChildren.map { it.name.localName }.any { it !in setOf("SignedInfo", "SignatureValue", "KeyInfo") } + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val signedInfo = signature.singleDirectElement(XMLDSIG_NAMESPACE, "SignedInfo") + val signatureValueElement = signature.singleDirectElement(XMLDSIG_NAMESPACE, "SignatureValue") + val keyInfo = signature.optionalDirectElement(XMLDSIG_NAMESPACE, "KeyInfo") + validateSignedInfoShape(signedInfo, targetId) + + val algorithmUri = signedInfo.singleDirectElement(XMLDSIG_NAMESPACE, "SignatureMethod").attribute("Algorithm") + ?: samlAbort(SamlErrorCode.SIGNATURE_INVALID) + val algorithm = SamlSignatureAlgorithm.entries.singleOrNull { it.uri == algorithmUri } + ?: samlAbort(SamlErrorCode.SIGNATURE_INVALID) + val reference = signedInfo.singleDirectElement(XMLDSIG_NAMESPACE, "Reference") + val digestValue = decodeBase64Text( + reference.singleDirectElement(XMLDSIG_NAMESPACE, "DigestValue"), + maximumBytes = 32 + ) + if (digestValue.size != 32) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + val canonicalTarget = canonicalizeExclusive(target, excludedElement = signature) + val actualDigest = try { + crypto.sha256(canonicalTarget) + } finally { + canonicalTarget.fill(0) + } + val digestMatches = try { + actualDigest.size == 32 && crypto.constantTimeEquals(digestValue, actualDigest) + } finally { + digestValue.fill(0) + actualDigest.fill(0) + } + if (!digestMatches) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + + val keyHint = keyInfo?.let(::readKeyHint) + val candidateKeys = metadata.verificationKeys.filter { key -> + (keyHint == null || key.keyId == keyHint) && key.validAt(now) && when (algorithm) { + SamlSignatureAlgorithm.RSA_SHA256 -> key is SamlVerificationKey.Rsa + SamlSignatureAlgorithm.ECDSA_SHA256 -> key is SamlVerificationKey.Es256 + } + } + if (candidateKeys.isEmpty()) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + + val canonicalSignedInfo = canonicalizeExclusive(signedInfo) + val signatureBytes = decodeBase64Text(signatureValueElement, maximumBytes = 8_192) + try { + candidateKeys.forEach { key -> + val verified = try { + when { + key is SamlVerificationKey.Rsa && algorithm == SamlSignatureAlgorithm.RSA_SHA256 -> + crypto.verifyRsaSha256( + key.publicKey, + canonicalSignedInfo, + RsaSha256Signature(signatureBytes) + ) + + key is SamlVerificationKey.Es256 && algorithm == SamlSignatureAlgorithm.ECDSA_SHA256 -> + crypto.verifyEs256( + key.publicKey, + canonicalSignedInfo, + Es256Signature(signatureBytes) + ) + + else -> false + } + } catch (_: IllegalArgumentException) { + false + } + if (verified) return VerifiedSamlSignature(key.keyId, algorithm) + } + } finally { + canonicalSignedInfo.fill(0) + signatureBytes.fill(0) + } + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + + private fun validateSignedInfoShape(signedInfo: SamlXmlElement, targetId: String) { + val children = signedInfo.children.filterIsInstance() + if (children.size != 3 || children.any { it.name.namespaceUri != XMLDSIG_NAMESPACE }) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + if (children.map { it.name.localName } != listOf("CanonicalizationMethod", "SignatureMethod", "Reference")) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val canonicalization = children[0] + if (canonicalization.attribute("Algorithm") != EXCLUSIVE_C14N || + canonicalization.children.any { it is SamlXmlElement } + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val signatureMethod = children[1] + if (signatureMethod.attribute("Algorithm") !in SamlSignatureAlgorithm.entries.map { it.uri } || + signatureMethod.children.any { it is SamlXmlElement } + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val reference = children[2] + if (reference.attribute("URI") != "#$targetId") samlAbort(SamlErrorCode.SIGNATURE_INVALID) + val referenceChildren = reference.children.filterIsInstance() + if (referenceChildren.size != 3 || + referenceChildren.map { it.name.localName } != listOf("Transforms", "DigestMethod", "DigestValue") || + referenceChildren.any { it.name.namespaceUri != XMLDSIG_NAMESPACE } + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val transforms = referenceChildren[0] + val transformElements = transforms.children.filterIsInstance() + if (transformElements.size != 2 || transformElements.any { + it.name.namespaceUri != XMLDSIG_NAMESPACE || it.name.localName != "Transform" || + it.children.any { child -> child is SamlXmlElement } + } || transformElements.map { it.attribute("Algorithm") } != listOf(ENVELOPED_SIGNATURE, EXCLUSIVE_C14N) + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val digestMethod = referenceChildren[1] + if (digestMethod.attribute("Algorithm") != SHA256_DIGEST || digestMethod.children.any { it is SamlXmlElement }) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + if (reference.attributes.any { it.name.localName != "URI" || it.name.namespaceUri.isNotEmpty() }) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + } + + private fun readKeyHint(keyInfo: SamlXmlElement): String? { + val children = keyInfo.children.filterIsInstance() + if (children.any { + it.name.namespaceUri != XMLDSIG_NAMESPACE || it.name.localName !in setOf("KeyName", "X509Data") + } + ) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val keyNames = keyInfo.directElements(XMLDSIG_NAMESPACE, "KeyName") + if (keyNames.size > 1) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + return keyNames.singleOrNull()?.normalizedText(255)?.also { + if (it.any(Char::isWhitespace)) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + } + + private fun decodeBase64Text(element: SamlXmlElement, maximumBytes: Int): ByteArray { + if (element.children.any { it !is SamlXmlText }) samlAbort(SamlErrorCode.SIGNATURE_INVALID) + val lexical = element.children.filterIsInstance().joinToString("") { it.value } + if (lexical.length > maximumBytes * 2 + 64 || lexical.any { it.isWhitespace() && it !in " \t\r\n" }) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + val compact = lexical.filterNot(Char::isWhitespace) + return try { + SamlBase64.decode(compact, maximumBytes) + } catch (_: IllegalArgumentException) { + samlAbort(SamlErrorCode.SIGNATURE_INVALID) + } + } + + private fun SamlVerificationKey.validAt(now: Instant): Boolean = + (validFrom == null || now >= validFrom!!) && (validUntil == null || now < validUntil!!) +} diff --git a/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlTypes.kt b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlTypes.kt new file mode 100644 index 0000000..1ffa345 --- /dev/null +++ b/aether-auth-saml/src/commonMain/kotlin/codes/yousef/aether/auth/saml/SamlTypes.kt @@ -0,0 +1,341 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.AuthenticationAssurance +import codes.yousef.aether.auth.AuditRequestMetadata +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.ExternalIdentityId +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.IdentityHttpMethod +import codes.yousef.aether.auth.IdentityHttpRequest +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.P256PublicKey +import codes.yousef.aether.auth.RsaPublicKey +import codes.yousef.aether.auth.SessionAuthenticationMethod +import codes.yousef.aether.auth.UserId +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.EncodeDefault + +private val PROVIDER_ID_PATTERN = Regex("[a-z0-9][a-z0-9_-]{0,62}") +private val KEY_ID_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,254}") + +/** Which SAML object must carry a valid signature. */ +@Serializable +enum class SamlSignaturePolicy { + @SerialName("assertion_or_response") ASSERTION_OR_RESPONSE, + @SerialName("assertion") ASSERTION, + @SerialName("response") RESPONSE, + @SerialName("both") BOTH +} + +@Serializable +enum class SamlSignatureAlgorithm(val uri: String) { + @SerialName("rsa_sha256") + RSA_SHA256("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"), + + @SerialName("ecdsa_sha256") + ECDSA_SHA256("http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256") +} + +/** A metadata key already decoded to the platform-neutral identity crypto boundary. */ +sealed interface SamlVerificationKey { + val keyId: String + val validFrom: Instant? + val validUntil: Instant? + + class Rsa( + override val keyId: String, + val publicKey: RsaPublicKey, + override val validFrom: Instant? = null, + override val validUntil: Instant? = null + ) : SamlVerificationKey { + init { validateKeyWindow(keyId, validFrom, validUntil) } + override fun toString(): String = "SamlVerificationKey.Rsa(keyId=$keyId, publicKey=)" + } + + class Es256( + override val keyId: String, + val publicKey: P256PublicKey, + override val validFrom: Instant? = null, + override val validUntil: Instant? = null + ) : SamlVerificationKey { + init { validateKeyWindow(keyId, validFrom, validUntil) } + override fun toString(): String = "SamlVerificationKey.Es256(keyId=$keyId, publicKey=)" + } +} + +private fun validateKeyWindow(keyId: String, validFrom: Instant?, validUntil: Instant?) { + require(KEY_ID_PATTERN.matches(keyId)) { "Invalid SAML metadata key ID" } + require(validFrom == null || validUntil == null || validUntil > validFrom) { + "SAML metadata key validity window is empty" + } +} + +/** + * Immutable, verified IdP metadata snapshot. A resolver may return overlapping old/new keys during + * rotation. Response KeyInfo is only a hint; it can never introduce a verification key. + */ +class SamlProviderMetadata( + val entityId: String, + val redirectSsoUrl: String, + verificationKeys: List, + val version: String, + val validUntil: Instant, + val wantAuthnRequestsSigned: Boolean = false +) { + val verificationKeys: List = verificationKeys.toList() + + init { + require(entityId.isNotBlank() && entityId.length <= 2_048) { "Invalid SAML metadata entity ID" } + require(version.isNotBlank() && version.length <= 255) { "Invalid SAML metadata version" } + require(this.verificationKeys.isNotEmpty() && this.verificationKeys.size <= 32) { + "SAML metadata must contain 1..32 signing keys" + } + require(this.verificationKeys.map { it.keyId }.toSet().size == this.verificationKeys.size) { + "SAML metadata key IDs must be unique" + } + requireSafeHttpsOrLoopbackUrl(redirectSsoUrl) + } + + override fun toString(): String = + "SamlProviderMetadata(entityId=$entityId, redirectSsoUrl=, verificationKeys=, " + + "version=$version, validUntil=$validUntil, wantAuthnRequestsSigned=$wantAuthnRequestsSigned)" +} + +/** Resolve a fresh/cached metadata snapshot. Implementations own fetching, pinning, and rotation. */ +fun interface SamlMetadataResolver { + suspend fun resolve(): SamlProviderMetadata +} + +class StaticSamlMetadataResolver(private val metadata: SamlProviderMetadata) : SamlMetadataResolver { + override suspend fun resolve(): SamlProviderMetadata = metadata +} + +/** Optional secret-owning signer for IdPs that require signed HTTP-Redirect AuthnRequests. */ +interface SamlRedirectSigner { + val algorithm: SamlSignatureAlgorithm + val keyId: String + suspend fun sign(queryBytes: ByteArray): ByteArray +} + +/** Immutable configuration for one tenant-scoped SAML identity provider. */ +class SamlProviderConfig( + val tenantId: OrganizationId, + val providerId: String, + val spEntityId: String, + val idpEntityId: String, + val assertionConsumerServiceUrl: String, + val enabled: Boolean = true, + val jitProvisioningEnabled: Boolean = false, + val signaturePolicy: SamlSignaturePolicy = SamlSignaturePolicy.ASSERTION_OR_RESPONSE, + allowedNameIdFormats: Set = setOf( + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + ), + val requestLifetime: Duration = 5.minutes, + val clockSkew: Duration = 30.seconds, + val maximumAssertionLifetime: Duration = 10.minutes, + val replayReceiptLifetime: Duration = 1.hours, + val maximumEncodedResponseBytes: Int = 2_097_152, + val maximumXmlBytes: Int = 1_048_576, + val maximumXmlDepth: Int = 25, + val maximumElements: Int = 2_048, + val maximumAttributesPerElement: Int = 30, + val maximumTextCharacters: Int = 262_144 +) { + val allowedNameIdFormats: Set = allowedNameIdFormats.toSet() + + init { + require(PROVIDER_ID_PATTERN.matches(providerId)) { "Invalid SAML provider ID" } + require(spEntityId.isNotBlank() && spEntityId.length <= 2_048) { "Invalid SAML SP entity ID" } + require(idpEntityId.isNotBlank() && idpEntityId.length <= 2_048) { "Invalid SAML IdP entity ID" } + requireSafeHttpsOrLoopbackUrl(assertionConsumerServiceUrl) + require(this.allowedNameIdFormats.isNotEmpty() && this.allowedNameIdFormats.size <= 16 && + this.allowedNameIdFormats.all { it.isNotBlank() && it.length <= 512 }) { + "Invalid SAML NameID format allowlist" + } + require(requestLifetime.isPositive() && requestLifetime <= 15.minutes) { + "SAML request lifetime must be in 1ns..15m" + } + require(!clockSkew.isNegative() && clockSkew <= 5.minutes) { "SAML clock skew must be in 0s..5m" } + require(maximumAssertionLifetime.isPositive() && maximumAssertionLifetime <= 24.hours) + require(replayReceiptLifetime.isPositive() && replayReceiptLifetime <= 24.hours) + require(maximumEncodedResponseBytes in 4_096..4_194_304) + require(maximumXmlBytes in 4_096..2_097_152) + // These hard ceilings intentionally match the May 2026 OpenSAML parser-hardening + // defaults. They are maximums, not merely defaults: a tenant configuration must not be + // able to weaken the authority-wide unauthenticated XML resource limits. + require(maximumXmlDepth in 8..25) + require(maximumElements in 64..10_000) + require(maximumAttributesPerElement in 8..30) + require(maximumTextCharacters in 4_096..1_048_576) + } + + override fun toString(): String = + "SamlProviderConfig(tenantId=$tenantId, providerId=$providerId, spEntityId=$spEntityId, " + + "idpEntityId=$idpEntityId, assertionConsumerServiceUrl=, enabled=$enabled, " + + "jitProvisioningEnabled=$jitProvisioningEnabled, signaturePolicy=$signaturePolicy)" +} + +@Serializable +enum class SamlErrorCode { + @SerialName("provider_disabled") PROVIDER_DISABLED, + @SerialName("provider_metadata_invalid") PROVIDER_METADATA_INVALID, + @SerialName("request_invalid") REQUEST_INVALID, + @SerialName("request_expired") REQUEST_EXPIRED, + @SerialName("response_invalid") RESPONSE_INVALID, + @SerialName("signature_invalid") SIGNATURE_INVALID, + @SerialName("assertion_replayed") ASSERTION_REPLAYED, + @SerialName("external_identity_not_linked") EXTERNAL_IDENTITY_NOT_LINKED, + @SerialName("external_identity_conflict") EXTERNAL_IDENTITY_CONFLICT, + @SerialName("provisioning_failed") PROVISIONING_FAILED, + @SerialName("store_unavailable") STORE_UNAVAILABLE +} + +@Serializable +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +data class SamlError( + val code: SamlErrorCode, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val message: String = code.genericMessage, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val retryable: Boolean = code.defaultRetryable +) { + init { + require(message == code.genericMessage) { "SAML errors must use the stable generic message" } + require(retryable == code.defaultRetryable) { "SAML retryability is fixed by error code" } + } +} + +sealed interface SamlResult { + data class Success(val value: T) : SamlResult + data class Failure(val error: SamlError) : SamlResult + + fun valueOrNull(): T? = (this as? Success)?.value +} + +internal val SamlErrorCode.genericMessage: String + get() = when (this) { + SamlErrorCode.PROVIDER_DISABLED -> "The identity provider is unavailable." + SamlErrorCode.PROVIDER_METADATA_INVALID -> "The identity provider configuration is invalid." + SamlErrorCode.REQUEST_INVALID -> "The identity request is invalid or has already been used." + SamlErrorCode.REQUEST_EXPIRED -> "The identity request has expired." + SamlErrorCode.RESPONSE_INVALID -> "The identity response is invalid." + SamlErrorCode.SIGNATURE_INVALID -> "The identity response signature is invalid." + SamlErrorCode.ASSERTION_REPLAYED -> "The identity response has already been used." + SamlErrorCode.EXTERNAL_IDENTITY_NOT_LINKED -> "The external identity is not linked." + SamlErrorCode.EXTERNAL_IDENTITY_CONFLICT -> "The external identity cannot be linked." + SamlErrorCode.PROVISIONING_FAILED -> "The external identity could not be provisioned." + SamlErrorCode.STORE_UNAVAILABLE -> "The identity service is temporarily unavailable." + } + +internal val SamlErrorCode.defaultRetryable: Boolean + get() = this == SamlErrorCode.STORE_UNAVAILABLE + +/** Narrow provider surface consumed by the common-code HTTP middleware. */ +interface SamlFederationProvider { + val configuredTenantId: OrganizationId + val configuredProviderId: String + + suspend fun beginAuthentication( + request: SamlAuthenticationRequest = SamlAuthenticationRequest() + ): SamlResult + suspend fun completeAuthentication(request: SamlPostResponseRequest): SamlResult +} + +class SamlAuthenticationRequest( + val linkToUserId: UserId? = null +) + +/** Caller-held request correlation. Keep it in an integrity-protected server-side session/cookie. */ +class SamlAuthenticationState internal constructor( + internal val challengeId: ChallengeId, + internal val requestId: String, + relayState: ByteArray, + internal val linkToUserId: UserId?, + val providerLease: FederationProviderLease, + val expiresAt: Instant +) { + private val relayStateValue = relayState.copyOf() + + init { + require(providerLease.kind == FederationProviderKind.SAML) { + "SAML authentication state requires a SAML provider lease" + } + } + + internal fun relayStateBytes(): ByteArray = relayStateValue.copyOf() + internal fun destroy() { relayStateValue.fill(0) } + override fun toString(): String = + "SamlAuthenticationState(challengeId=, requestId=, relayState=, expiresAt=$expiresAt)" +} + +class SamlAuthenticationStart internal constructor( + val redirectUrl: String, + val state: SamlAuthenticationState, + val expiresAt: Instant +) { + override fun toString(): String = "SamlAuthenticationStart(redirectUrl=, state=, expiresAt=$expiresAt)" +} + +class SamlPostResponseRequest( + val samlResponse: String, + val relayState: String, + val state: SamlAuthenticationState, + val auditRequest: AuditRequestMetadata? = null +) { + init { + require(samlResponse.isNotEmpty()) { "SAMLResponse must not be empty" } + require(relayState.length in 16..80 && relayState.none(Char::isWhitespace)) { "Invalid RelayState" } + } + + override fun toString(): String = + "SamlPostResponseRequest(samlResponse=, relayState=, state=, auditRequest=$auditRequest)" +} + +data class SamlVerifiedClaims( + val issuer: String, + val subject: ExternalSubject, + val nameIdFormat: String, + val issuedAt: Instant, + val authenticatedAt: Instant, + val expiresAt: Instant, + val sessionIndex: String?, + val authenticationContext: String?, + val attributes: Map> +) { + override fun toString(): String = + "SamlVerifiedClaims(issuer=$issuer, subject=, nameIdFormat=$nameIdFormat, issuedAt=$issuedAt, " + + "authenticatedAt=$authenticatedAt, expiresAt=$expiresAt, sessionIndex=, " + + "authenticationContext=, attributes=)" +} + +data class SamlAuthenticationResult( + val userId: UserId, + val externalIdentityId: ExternalIdentityId, + val providerLease: FederationProviderLease, + val assurance: AuthenticationAssurance = AuthenticationAssurance.SESSION, + val authenticationMethod: SessionAuthenticationMethod = SessionAuthenticationMethod.SAML, + val passkeyStepUpRequiredForSensitiveActions: Boolean = true, + val claims: SamlVerifiedClaims +) { + init { + require(providerLease.kind == FederationProviderKind.SAML && + assurance == AuthenticationAssurance.SESSION && + authenticationMethod == SessionAuthenticationMethod.SAML && + passkeyStepUpRequiredForSensitiveActions + ) { "SAML authentication results require a SAML session lease and passkey step-up" } + } +} + +private fun requireSafeHttpsOrLoopbackUrl(value: String) { + require(value.length in 8..4_096 && '#' !in value) { "Invalid SAML endpoint URL" } + IdentityHttpRequest(IdentityHttpMethod.GET, value) +} diff --git a/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlErrorContractTest.kt b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlErrorContractTest.kt new file mode 100644 index 0000000..b0a5a7b --- /dev/null +++ b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlErrorContractTest.kt @@ -0,0 +1,82 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.ExternalSubject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.time.Instant + +class SamlErrorContractTest { + @Test + fun `protocol errors own their stable message and retryability`() { + val failure = SamlError(SamlErrorCode.STORE_UNAVAILABLE) + + assertEquals("The identity service is temporarily unavailable.", failure.message) + assertTrue(failure.retryable) + val encoded = Json.encodeToString(failure) + assertTrue("\"message\":\"The identity service is temporarily unavailable.\"" in encoded) + assertTrue("\"retryable\":true" in encoded) + assertEquals(failure, Json.decodeFromString(encoded)) + assertFailsWith { + SamlError(SamlErrorCode.STORE_UNAVAILABLE, "assertion exception: secret", true) + } + assertFailsWith { + SamlError(SamlErrorCode.RESPONSE_INVALID, retryable = true) + } + } + + @Test + fun `HTTP errors preserve the complete stable wire envelope`() { + val failure = SamlFederationHttpError( + code = SamlFederationHttpErrorCode.SERVICE_UNAVAILABLE, + requestId = "request-123" + ) + + val encoded = Json.encodeToString(failure) + + assertTrue("\"code\":\"service_unavailable\"" in encoded) + assertTrue("\"message\":\"The identity service is temporarily unavailable.\"" in encoded) + assertTrue("\"requestId\":\"request-123\"" in encoded) + assertTrue("\"retryable\":true" in encoded) + assertEquals(failure, Json.decodeFromString(encoded)) + assertFailsWith { + SamlFederationHttpError( + code = SamlFederationHttpErrorCode.REQUEST_INVALID, + message = "assertion exception: secret", + requestId = "request-123", + retryable = false + ) + } + assertFailsWith { + SamlFederationHttpError( + code = SamlFederationHttpErrorCode.REQUEST_INVALID, + requestId = "request-123", + retryable = true + ) + } + } + + @Test + fun `SAML verified-claim diagnostics redact claim PII`() { + val marker = "never-print-this-saml-pii" + val now = Instant.parse("2026-01-01T00:00:00Z") + val claims = SamlVerifiedClaims( + issuer = "https://idp.example", + subject = ExternalSubject(marker), + nameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + issuedAt = now, + authenticatedAt = now, + expiresAt = now, + sessionIndex = marker, + authenticationContext = marker, + attributes = mapOf("email" to listOf("$marker@example.com")) + ) + + assertFalse(marker in claims.toString()) + } +} diff --git a/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlFederationHttpMiddlewareTest.kt b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlFederationHttpMiddlewareTest.kt new file mode 100644 index 0000000..c0892ff --- /dev/null +++ b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlFederationHttpMiddlewareTest.kt @@ -0,0 +1,763 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.AuditAction +import codes.yousef.aether.auth.AuthenticationAssurance +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.ExternalIdentityId +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.FederationCallbackStateConsumeResult +import codes.yousef.aether.auth.FederationCallbackStateStore +import codes.yousef.aether.auth.FederationCallbackStateWriteResult +import codes.yousef.aether.auth.FederationProviderControl +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.FederationProviderLease +import codes.yousef.aether.auth.FederatedIdentitySessionService +import codes.yousef.aether.auth.IdentityConfig +import codes.yousef.aether.auth.IdentityContext +import codes.yousef.aether.auth.IdentityContextAttributeKey +import codes.yousef.aether.auth.IdentityEnvironment +import codes.yousef.aether.auth.IdentityFederationProviderManager +import codes.yousef.aether.auth.IdentityKeyConfig +import codes.yousef.aether.auth.IdentityPrincipal +import codes.yousef.aether.auth.IdentityPrincipalKind +import codes.yousef.aether.auth.IdentitySession +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.RelyingPartyConfig +import codes.yousef.aether.auth.SecretReference +import codes.yousef.aether.auth.SessionAuthenticationMethod +import codes.yousef.aether.auth.SessionState +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.DeterministicIdentitySecretResolver +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.InMemoryIdentityStore +import codes.yousef.aether.auth.testkit.InMemoryIdentityStoreSeed +import codes.yousef.aether.core.Attributes +import codes.yousef.aether.core.Cookie +import codes.yousef.aether.core.Cookies +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.Headers +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.Request +import codes.yousef.aether.core.RequestConnection +import codes.yousef.aether.core.Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +class SamlFederationHttpMiddlewareTest { + @Test + fun `protected callback state round-trips the complete provider lease`() = runTest { + val now = IdentityFixtures.baseInstant + val state = SamlServerCallbackState( + providerLease = PROVIDER_LEASE, + authenticationState = SamlAuthenticationState( + challengeId = ChallengeId("challenge-protection"), + requestId = "_challenge-protection", + relayState = RELAY_BYTES, + linkToUserId = USER_ID, + providerLease = PROVIDER_LEASE, + expiresAt = now + 5.minutes + ), + predecessorSessionId = null, + expectedPredecessorVersion = null, + expiresAt = now + 5.minutes + ) + + val restored = state.useForProtection { + lease, challengeId, requestId, relayState, linkToUserId, + predecessorSessionId, expectedPredecessorVersion, expiresAt -> + SamlServerCallbackState.restore( + providerLease = lease, + challengeId = challengeId, + requestId = requestId, + relayState = relayState, + linkToUserId = linkToUserId, + expiresAt = expiresAt, + predecessorSessionId = predecessorSessionId, + expectedPredecessorVersion = expectedPredecessorVersion + ) + } + + assertEquals(PROVIDER_LEASE, restored.providerLease) + assertEquals(PROVIDER_LEASE, restored.authenticationState.providerLease) + assertFailsWith { + SamlServerCallbackState( + providerLease = PROVIDER_LEASE.copy(version = 1), + authenticationState = restored.authenticationState, + predecessorSessionId = null, + expectedPredecessorVersion = null, + expiresAt = restored.expiresAt + ) + } + state.destroy() + restored.destroy() + } + + @Test + fun `redirect start and POST callback keep SAML state server-side and create provenance session`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + + assertEquals(302, start.response.statusCode) + assertTrue(start.response.headers.build()["Location"]!!.startsWith("$SSO_ENDPOINT?")) + val stateCookie = start.response.cookies.single { it.name == STATE_COOKIE } + assertTrue(stateCookie.secure) + assertTrue(stateCookie.httpOnly) + assertEquals(Cookie.SameSite.NONE, stateCookie.sameSite) + assertEquals(43, stateCookie.value.length) + assertFalse(stateCookie.value.contains(USER_ID.value)) + assertFalse(stateCookie.value.contains(TENANT_ID.value)) + assertFalse(stateCookie.value.contains(PROVIDER_ID)) + assertFalse(start.response.headers.build()["Location"]!!.contains(stateCookie.value)) + assertFalse(start.response.bodyText().contains("RelayState", ignoreCase = true)) + assertEquals(1, fixture.states.size) + + val callback = fixture.callback(stateCookie.value) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(303, callback.response.statusCode) + assertEquals(SUCCESS_REDIRECT, callback.response.headers.build()["Location"]) + assertFalse(callback.response.headers.build()["Location"]!!.contains("csrf", ignoreCase = true)) + val sessionCookie = callback.response.cookies.single { it.name == fixture.config.cookie.name } + val csrfCookie = callback.response.cookies.single { it.name == CSRF_COOKIE } + assertTrue(sessionCookie.httpOnly) + assertEquals(Cookie.SameSite.LAX, sessionCookie.sameSite) + assertFalse(csrfCookie.httpOnly) + assertTrue(csrfCookie.secure) + assertEquals(300, csrfCookie.maxAge) + assertEquals(0, fixture.states.size) + + val snapshot = fixture.store.snapshot() + val session = snapshot.sessions.single() + assertEquals(AuthenticationAssurance.SESSION, session.assurance) + assertEquals(SessionAuthenticationMethod.SAML, session.authenticationMethod) + assertEquals(TENANT_ID, session.federationOrganizationId) + assertEquals(PROVIDER_STORAGE_KEY, session.federationProviderKey) + assertEquals(EXTERNAL_IDENTITY_ID, session.externalIdentityId) + assertEquals(AuditAction.SESSION_CREATED, snapshot.auditEvents.single().action) + assertFalse(session.tokenDigest.encoded in sessionCookie.value) + assertTrue(fixture.provider.authenticationStateSeen!!.relayStateBytes().all { it == 0.toByte() }) + } + + @Test + fun `callback rotates the authenticated predecessor before setting the federated cookie`() = runTest { + val fixture = Fixture(withPredecessor = true) + val start = fixture.authenticate(fixture.exchange(HttpMethod.GET, fixture.startPath)) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(303, callback.response.statusCode) + val predecessor = requireNotNull(fixture.predecessor) + val snapshot = fixture.store.snapshot() + val rotated = snapshot.sessions.single { it.id == predecessor.id } + val replacement = snapshot.sessions.single { it.id != predecessor.id } + assertEquals(SessionState.ROTATED, rotated.state) + assertEquals(replacement.id, rotated.rotatedToId) + assertEquals(predecessor.id, replacement.rotatedFromId) + assertEquals(predecessor.familyId, replacement.familyId) + assertEquals(SessionAuthenticationMethod.SAML, replacement.authenticationMethod) + assertEquals(AuditAction.SESSION_ROTATED, snapshot.auditEvents.single().action) + } + + @Test + fun `callback rejects a federated user without an active tenant membership`() = runTest { + val fixture = Fixture(membershipState = MembershipState.SUSPENDED) + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(400, callback.response.statusCode) + assertEquals(emptyList(), fixture.store.snapshot().sessions) + assertEquals(emptyList(), fixture.store.snapshot().auditEvents) + assertGenericError(callback) + } + + @Test + fun `callback state is tenant-bound and atomically single use`() = runTest { + val fixture = Fixture(includeSecondProvider = true) + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val mismatch = fixture.callback(selector, providerId = "other") + fixture.middleware.asMiddleware()(mismatch) { error("Federation route must not fall through") } + assertEquals(400, mismatch.response.statusCode) + assertEquals(0, fixture.otherProvider.callbackCount) + assertEquals(0, fixture.states.size) + + val replay = fixture.callback(selector) + fixture.middleware.asMiddleware()(replay) { error("Federation route must not fall through") } + assertEquals(400, replay.response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + assertEquals(0, fixture.store.snapshot().sessions.size) + assertGenericError(replay) + } + + @Test + fun `provider-disabled callback is mapped to indistinguishable not found`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.enabled = false + + val disabled = fixture.callback(selector) + fixture.middleware.asMiddleware()(disabled) { error("Federation route must not fall through") } + + assertEquals(404, disabled.response.statusCode) + assertEquals(1, fixture.provider.callbackCount) + assertEquals(0, fixture.states.size) + assertEquals(0, fixture.store.snapshot().sessions.size) + assertGenericError(disabled) + } + + @Test + fun `disable immediately before session creation rejects the stale lease`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.beforeSuccess = { fixture.disableProvider() } + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(400, callback.response.statusCode) + assertEquals(emptyList(), fixture.store.snapshot().sessions) + assertGenericError(callback) + } + + @Test + fun `disable then re-enable before session creation still rejects the stale callback lease`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.beforeSuccess = { + fixture.disableProvider() + fixture.enableProvider() + } + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(400, callback.response.statusCode) + assertEquals(emptyList(), fixture.store.snapshot().sessions) + assertGenericError(callback) + } + + @Test + fun `callback result with a different provider lease is rejected before session creation`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.resultLease = PROVIDER_LEASE.copy(version = PROVIDER_LEASE.version + 1) + + val callback = fixture.callback(selector) + fixture.middleware.asMiddleware()(callback) { error("Federation route must not fall through") } + + assertEquals(400, callback.response.statusCode) + assertEquals(emptyList(), fixture.store.snapshot().sessions) + assertGenericError(callback) + } + + @Test + fun `cross-site POST without the correlation cookie fails before assertion handling`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val body = "SAMLResponse=$SAML_RESPONSE&RelayState=$RELAY_STATE".encodeToByteArray() + val missing = fixture.exchange( + HttpMethod.POST, + fixture.callbackPath, + headers = Headers.of( + "Content-Type" to "application/x-www-form-urlencoded", + "Content-Length" to body.size.toString() + ), + body = body + ) + + fixture.middleware.asMiddleware()(missing) { error("Federation route must not fall through") } + + assertEquals(400, missing.response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + assertEquals(1, fixture.states.size) + val missingClearCookie = missing.response.cookies.single { it.name == STATE_COOKIE } + assertEquals(0, missingClearCookie.maxAge) + assertEquals(Cookie.SameSite.NONE, missingClearCookie.sameSite) + assertGenericError(missing) + + val wrongSelector = Base64Url.encode(ByteArray(32) { 0x6f }) + val mismatched = fixture.callback(wrongSelector) + fixture.middleware.asMiddleware()(mismatched) { error("Federation route must not fall through") } + assertEquals(400, mismatched.response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + assertEquals(1, fixture.states.size) + val mismatchClearCookie = mismatched.response.cookies.single { it.name == STATE_COOKIE } + assertEquals(0, mismatchClearCookie.maxAge) + assertEquals(Cookie.SameSite.NONE, mismatchClearCookie.sameSite) + assertGenericError(mismatched) + } + + @Test + fun `strict method content type form shape and body bounds reject malformed callbacks`() = runTest { + val fixture = Fixture() + suspend fun execute(exchange: TestExchange): TestExchange { + fixture.middleware.asMiddleware()(exchange) { error("Federation route must not fall through") } + return exchange + } + + assertEquals(405, execute(fixture.exchange(HttpMethod.POST, fixture.startPath)).response.statusCode) + assertEquals(400, execute(fixture.exchange(HttpMethod.GET, fixture.startPath, query = "returnTo=evil")).response.statusCode) + + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + + val wrongMethod = fixture.exchange( + HttpMethod.GET, + fixture.callbackPath, + headers = Headers.of("Cookie" to "$STATE_COOKIE=$selector"), + cookies = Cookies.of(Cookie(STATE_COOKIE, selector)) + ) + assertEquals(405, execute(wrongMethod).response.statusCode) + + val wrongType = fixture.callback( + selector, + headers = Headers.of( + "Cookie" to "$STATE_COOKIE=$selector", + "Content-Type" to "application/xml" + ) + ) + assertEquals(400, execute(wrongType).response.statusCode) + assertEquals(0, wrongType.requestValue.bodyReads) + + val duplicate = fixture.callback( + selector, + bodyText = "SAMLResponse=$SAML_RESPONSE&SAMLResponse=duplicate&RelayState=$RELAY_STATE" + ) + assertEquals(400, execute(duplicate).response.statusCode) + assertEquals(0, fixture.provider.callbackCount) + + val declaredOversize = fixture.callback( + selector, + headers = Headers.of( + "Cookie" to "$STATE_COOKIE=$selector", + "Content-Type" to "application/x-www-form-urlencoded", + "Content-Length" to "4097" + ), + bodyText = "x" + ) + assertEquals(400, execute(declaredOversize).response.statusCode) + assertEquals(0, declaredOversize.requestValue.bodyReads) + } + + @Test + fun `assertions provider exceptions and internal details are redacted`() = runTest { + val fixture = Fixture() + val start = fixture.exchange(HttpMethod.GET, fixture.startPath) + fixture.middleware.asMiddleware()(start) { error("Federation route must not fall through") } + val selector = start.response.cookies.single { it.name == STATE_COOKIE }.value + fixture.provider.callbackFailure = IllegalStateException( + "assertion $SAML_RESPONSE contains signing-key super-secret" + ) + + val failed = fixture.callback(selector) + fixture.middleware.asMiddleware()(failed) { error("Federation route must not fall through") } + + assertEquals(503, failed.response.statusCode) + assertGenericError(failed) + val body = failed.response.bodyText() + assertFalse(body.contains(SAML_RESPONSE)) + assertFalse(body.contains("assertion", ignoreCase = true)) + assertFalse(body.contains("signing-key", ignoreCase = true)) + assertEquals(0, fixture.store.snapshot().sessions.size) + } + + @Test + fun `not-owned resolution composes with another federation adapter`() = runTest { + val fixture = Fixture(notOwned = true) + val exchange = fixture.exchange(HttpMethod.GET, fixture.startPath) + var continued = false + + fixture.middleware.asMiddleware()(exchange) { continued = true } + + assertTrue(continued) + assertEquals(200, exchange.response.statusCode) + assertEquals(0, exchange.requestValue.bodyReads) + } + + @Test + fun `IdP redirect outside the configured endpoint allowlist is never emitted`() = runTest { + val fixture = Fixture() + fixture.provider.redirectUrl = "https://attacker.example.test/sso?SAMLRequest=stolen" + val exchange = fixture.exchange(HttpMethod.GET, fixture.startPath) + + fixture.middleware.asMiddleware()(exchange) { error("Federation route must not fall through") } + + assertEquals(503, exchange.response.statusCode) + assertEquals(null, exchange.response.headers.build()["Location"]) + assertEquals(0, fixture.states.size) + assertGenericError(exchange) + } + + private class Fixture( + includeSecondProvider: Boolean = false, + private val notOwned: Boolean = false, + withPredecessor: Boolean = false, + membershipState: MembershipState = MembershipState.ACTIVE + ) { + val config = identityConfig() + private val secretResolver = DeterministicIdentitySecretResolver( + mapOf(config.keys.sessionPepper to ByteArray(32) { 0x42 }) + ) + private val deterministic = DeterministicIdentityRuntime(deterministicSecrets = secretResolver) + private val user = IdentityFixtures.user(USER_ID) + private val organization = IdentityFixtures.organization(TENANT_ID) + private val membership = IdentityFixtures.membership( + organizationId = TENANT_ID, + userId = USER_ID, + state = membershipState + ) + val predecessor: IdentitySession? = if (withPredecessor) { + IdentityFixtures.session( + id = IdentityFixtures.sessionId("saml-callback-predecessor"), + userId = USER_ID + ) + } else { + null + } + val store = InMemoryIdentityStore( + InMemoryIdentityStoreSeed( + users = listOf(user), + sessions = listOfNotNull(predecessor), + organizations = listOf(organization), + memberships = listOf(membership), + federationProviderControls = listOf( + FederationProviderControl( + organizationId = TENANT_ID, + kind = FederationProviderKind.SAML, + providerId = PROVIDER_ID, + storageKey = PROVIDER_STORAGE_KEY, + createdAt = IdentityFixtures.baseInstant, + updatedAt = IdentityFixtures.baseInstant + ) + ) + ) + ) + val states = InMemoryCallbackStates() + val provider = FakeProvider(PROVIDER_ID, deterministic) + val otherProvider = FakeProvider("other", deterministic) + private val registrations = buildMap { + put(PROVIDER_ID, registration(provider)) + if (includeSecondProvider) put("other", registration(otherProvider)) + } + val middleware = SamlFederationHttpMiddleware( + runtime = deterministic.runtime, + identityConfig = config, + providers = SamlFederationProviderRegistry { tenantId, providerId -> + when { + notOwned -> SamlFederationProviderResolution.NotOwned + tenantId == TENANT_ID && registrations[providerId] != null -> + SamlFederationProviderResolution.Found(registrations.getValue(providerId)) + else -> SamlFederationProviderResolution.Missing + } + }, + callbackStates = states, + sessions = FederatedIdentitySessionService(store, deterministic.runtime, config), + config = SamlFederationHttpConfig(maximumBodyBytes = 4_096, maximumEncodedResponseCharacters = 4_096) + ) + val startPath = "/identity/v1/federation/${TENANT_ID.value}/$PROVIDER_ID/start" + val callbackPath = "/identity/v1/federation/${TENANT_ID.value}/$PROVIDER_ID/callback" + + suspend fun disableProvider() { + assertIs>( + IdentityFederationProviderManager(store, deterministic.runtime).disableProvider( + TENANT_ID, + FederationProviderKind.SAML, + PROVIDER_ID, + PROVIDER_STORAGE_KEY, + reasonCode = "saml_test_disabled" + ) + ) + } + + suspend fun enableProvider() { + assertIs>( + IdentityFederationProviderManager(store, deterministic.runtime).enableProvider( + TENANT_ID, + FederationProviderKind.SAML, + PROVIDER_ID, + PROVIDER_STORAGE_KEY, + reasonCode = "saml_test_enabled" + ) + ) + } + + fun exchange( + method: HttpMethod, + path: String, + query: String? = null, + headers: Headers = Headers.Empty, + cookies: Cookies = Cookies.Empty, + body: ByteArray = ByteArray(0) + ): TestExchange = TestExchange(method, path, query, headers, cookies, body) + + fun authenticate(exchange: TestExchange): TestExchange = exchange.also { authenticated -> + predecessor?.let { session -> + authenticated.attributes.put( + IdentityContextAttributeKey, + IdentityContext( + principal = IdentityPrincipal( + kind = IdentityPrincipalKind.USER, + userId = user.id, + displayName = user.displayName, + assurance = session.assurance, + authenticatedAt = session.authenticatedAt, + sessionId = session.id + ), + session = session + ) + ) + } + } + + fun callback( + selector: String, + providerId: String = PROVIDER_ID, + headers: Headers? = null, + bodyText: String = "SAMLResponse=$SAML_RESPONSE&RelayState=$RELAY_STATE" + ): TestExchange { + val body = bodyText.encodeToByteArray() + return exchange( + HttpMethod.POST, + "/identity/v1/federation/${TENANT_ID.value}/$providerId/callback", + headers = headers ?: Headers.of( + "Cookie" to "$STATE_COOKIE=$selector", + "Content-Type" to "application/x-www-form-urlencoded; charset=UTF-8", + "Content-Length" to body.size.toString(), + "User-Agent" to "Test Browser/1.0" + ), + cookies = Cookies.of(Cookie(STATE_COOKIE, selector)), + body = body + ) + } + + private fun registration(provider: FakeProvider) = SamlFederationProviderRegistration( + provider = provider, + allowedSsoRedirectEndpoints = setOf(SSO_ENDPOINT), + successRedirectUrl = SUCCESS_REDIRECT + ) + } + + private class FakeProvider( + override val configuredProviderId: String, + private val deterministic: DeterministicIdentityRuntime + ) : SamlFederationProvider { + override val configuredTenantId: OrganizationId = TENANT_ID + var enabled: Boolean = true + var callbackCount: Int = 0 + var callbackFailure: Throwable? = null + var beforeSuccess: suspend () -> Unit = {} + var resultLease: FederationProviderLease = PROVIDER_LEASE + var redirectUrl: String = "$SSO_ENDPOINT?SAMLRequest=fake-request&RelayState=$RELAY_STATE" + var authenticationStateSeen: SamlAuthenticationState? = null + + override suspend fun beginAuthentication( + request: SamlAuthenticationRequest + ): SamlResult { + if (!enabled) return SamlResult.Failure(SamlError(SamlErrorCode.PROVIDER_DISABLED)) + val now = deterministic.deterministicClock.now() + val state = SamlAuthenticationState( + challengeId = ChallengeId("challenge-$configuredProviderId"), + requestId = "_challenge-$configuredProviderId", + relayState = RELAY_BYTES, + linkToUserId = null, + providerLease = PROVIDER_LEASE, + expiresAt = now + 5.minutes + ) + return SamlResult.Success( + SamlAuthenticationStart( + redirectUrl = redirectUrl, + state = state, + expiresAt = state.expiresAt + ) + ) + } + + override suspend fun completeAuthentication( + request: SamlPostResponseRequest + ): SamlResult { + callbackCount += 1 + authenticationStateSeen = request.state + callbackFailure?.let { throw it } + if (!enabled) return SamlResult.Failure(SamlError(SamlErrorCode.PROVIDER_DISABLED)) + if (request.samlResponse != SAML_RESPONSE || request.relayState != RELAY_STATE) { + return SamlResult.Failure(SamlError(SamlErrorCode.RESPONSE_INVALID)) + } + beforeSuccess() + val now = deterministic.deterministicClock.now() + return SamlResult.Success( + SamlAuthenticationResult( + userId = USER_ID, + externalIdentityId = EXTERNAL_IDENTITY_ID, + providerLease = resultLease, + claims = SamlVerifiedClaims( + issuer = "https://idp.example.test", + subject = ExternalSubject("external-subject"), + nameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + issuedAt = now, + authenticatedAt = now, + expiresAt = now + 5.minutes, + sessionIndex = "session-index", + authenticationContext = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + attributes = emptyMap() + ) + ) + ) + } + } + + private class InMemoryCallbackStates : FederationCallbackStateStore { + private val values = mutableMapOf() + val size: Int get() = values.size + + override suspend fun store( + selector: String, + state: SamlServerCallbackState + ): FederationCallbackStateWriteResult = if (values.containsKey(selector)) { + FederationCallbackStateWriteResult.Conflict + } else { + values[selector] = state + FederationCallbackStateWriteResult.Stored + } + + override suspend fun consume( + selector: String + ): FederationCallbackStateConsumeResult = + values.remove(selector)?.let { FederationCallbackStateConsumeResult.Consumed(it) } + ?: FederationCallbackStateConsumeResult.Missing + } + + private companion object { + val TENANT_ID = OrganizationId("01900000-0000-7000-8000-000000000200") + val USER_ID = UserId("01900000-0000-7000-8000-000000000201") + val EXTERNAL_IDENTITY_ID = ExternalIdentityId("01900000-0000-7000-8000-000000000202") + val RELAY_BYTES = ByteArray(32) { (it + 1).toByte() } + val RELAY_STATE = Base64Url.encode(RELAY_BYTES) + const val PROVIDER_ID = "enterprise" + val PROVIDER_STORAGE_KEY = IdentityFixtures.federationProviderStorageKey( + FederationProviderKind.SAML, + "saml-middleware-enterprise" + ) + val PROVIDER_LEASE = FederationProviderLease( + organizationId = TENANT_ID, + kind = FederationProviderKind.SAML, + providerId = PROVIDER_ID, + storageKey = PROVIDER_STORAGE_KEY, + sessionEpoch = 0, + version = 0 + ) + const val SAML_RESPONSE = "encoded-saml-assertion-value" + const val SSO_ENDPOINT = "https://idp.example.test/sso" + const val SUCCESS_REDIRECT = "https://identity.example.test/account/security" + const val STATE_COOKIE = "__Host-aether_saml_state" + const val CSRF_COOKIE = "__Host-aether_csrf" + } +} + +private class TestRequest( + override val method: HttpMethod, + override val path: String, + override val query: String?, + override val headers: Headers, + override val cookies: Cookies, + private val body: ByteArray +) : Request { + override val uri: String = if (query == null) path else "$path?$query" + override val connection: RequestConnection = + RequestConnection("https", "identity.example.test", "127.0.0.1") + var bodyReads: Int = 0 + private set + + override suspend fun bodyBytes(): ByteArray { + bodyReads += 1 + return body.copyOf() + } +} + +private class TestResponse : Response { + override var statusCode: Int = 200 + override var statusMessage: String? = null + override val headers = Headers.HeadersBuilder() + override val cookies = mutableListOf() + private val body = mutableListOf() + + override suspend fun write(data: ByteArray) { body += data.toList() } + override suspend fun end() = Unit + fun bodyText(): String = body.toByteArray().decodeToString() +} + +private class TestExchange( + method: HttpMethod, + path: String, + query: String?, + headers: Headers, + cookies: Cookies, + body: ByteArray +) : Exchange { + val requestValue = TestRequest(method, path, query, headers, cookies, body) + override val request: Request = requestValue + override val response = TestResponse() + override val attributes = Attributes() +} + +private fun identityConfig(): IdentityConfig { + fun secret(name: String) = SecretReference("test", name, "v1", IdentityEnvironment.TEST) + return IdentityConfig( + environment = IdentityEnvironment.TEST, + publicBaseUrl = "https://identity.example.test", + relyingParty = RelyingPartyConfig( + id = "identity.example.test", + name = "SAML middleware test", + allowedOrigins = setOf("https://identity.example.test") + ), + keys = IdentityKeyConfig( + sessionPepper = secret("session"), + recoveryPepper = secret("recovery"), + deviceTokenPepper = secret("device"), + serviceCredentialPepper = secret("service"), + auditPseudonymizationKey = secret("audit"), + encryptionKey = secret("encryption"), + signingKey = secret("signing") + ) + ) +} + +private fun assertGenericError(exchange: TestExchange) { + val payload = Json.parseToJsonElement(exchange.response.bodyText()).jsonObject + assertEquals(setOf("code", "message", "requestId", "retryable"), payload.keys) + assertFalse(payload.getValue("message").toString().contains("secret", ignoreCase = true)) + assertEquals("no-store", exchange.response.headers.build()["Cache-Control"]) + assertNotNull(exchange.response.headers.build()["X-Content-Type-Options"]) +} diff --git a/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlIdentityProviderTest.kt b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlIdentityProviderTest.kt new file mode 100644 index 0000000..d3dcf09 --- /dev/null +++ b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlIdentityProviderTest.kt @@ -0,0 +1,611 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.FederationProviderKind +import codes.yousef.aether.auth.IdentityFederationProviderManager +import codes.yousef.aether.auth.IdentityOperationResult +import codes.yousef.aether.auth.IdentityStore +import codes.yousef.aether.auth.IdentityStoreError +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.LinkExternalIdentityCommand +import codes.yousef.aether.auth.ExternalIdentityLinkCommit +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.P256PublicKey +import codes.yousef.aether.auth.RsaPublicKey +import codes.yousef.aether.auth.StoreResult +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.InMemoryIdentityStore +import codes.yousef.aether.auth.testkit.InMemoryIdentityStoreSeed +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest + +class SamlIdentityProviderTest { + @Test + fun signedAssertionAuthenticatesOnceAndReplayIsRejected() = runTest { + val scenario = scenario() + val response = scenario.signedResponse() + + assertIs>(scenario.complete(response)) + val replay = assertIs(scenario.complete(response)) + assertEquals(SamlErrorCode.REQUEST_INVALID, replay.error.code) + assertEquals(1, scenario.store.snapshot().replayReceipts.size) + } + + @Test + fun concurrentPostCompletionHasExactlyOneWinner() = runTest { + val scenario = scenario() + val response = scenario.signedResponse() + + val outcomes = listOf( + async { scenario.complete(response) }, + async { scenario.complete(response) } + ).awaitAll() + + assertEquals(1, outcomes.count { it is SamlResult.Success }) + assertEquals(1, outcomes.count { it is SamlResult.Failure }) + assertEquals(1, scenario.store.snapshot().replayReceipts.size) + } + + @Test + fun signatureWrappingWithASecondAssertionIsRejected() = runTest { + val scenario = scenario() + val response = scenario.signedResponse(extraAssertion = true) + + val failure = assertIs(scenario.complete(response)) + assertEquals(SamlErrorCode.RESPONSE_INVALID, failure.error.code) + assertTrue(scenario.store.snapshot().replayReceipts.isEmpty()) + } + + @Test + fun recipientAndTimeWindowsAreExact() = runTest { + val wrongRecipient = scenario() + val recipientFailure = assertIs( + wrongRecipient.complete( + wrongRecipient.signedResponse(recipient = "https://sp.example.test/identity/v1/federation/wrong") + ) + ) + assertEquals(SamlErrorCode.RESPONSE_INVALID, recipientFailure.error.code) + + val expired = scenario() + val timeFailure = assertIs( + expired.complete(expired.signedResponse(expiresAt = expired.now - 1.minutes)) + ) + assertEquals(SamlErrorCode.RESPONSE_INVALID, timeFailure.error.code) + } + + @Test + fun xxeAndDuplicateIdsAreRejectedBeforeSignatureWork() = runTest { + val xxe = scenario() + val valid = xxe.signedResponse() + val malicious = "]>$valid" + val xxeFailure = assertIs(xxe.complete(malicious)) + assertEquals(SamlErrorCode.RESPONSE_INVALID, xxeFailure.error.code) + + val duplicate = scenario() + val duplicateXml = duplicate.signedResponse(extraAssertion = true) + .replace("ID=\"_assertion-extra\"", "ID=\"_assertion\"") + val duplicateFailure = assertIs(duplicate.complete(duplicateXml)) + assertEquals(SamlErrorCode.RESPONSE_INVALID, duplicateFailure.error.code) + } + + @Test + fun sha1AndUnknownTransformsAreRejected() = runTest { + val sha1 = scenario() + val sha1Failure = assertIs( + sha1.complete( + sha1.signedResponse( + signatureAlgorithm = "http://www.w3.org/2000/09/xmldsig#rsa-sha1" + ) + ) + ) + assertEquals(SamlErrorCode.SIGNATURE_INVALID, sha1Failure.error.code) + + val transform = scenario() + val transformFailure = assertIs( + transform.complete( + transform.signedResponse( + canonicalizationTransform = "http://www.w3.org/TR/1999/REC-xpath-19991116" + ) + ) + ) + assertEquals(SamlErrorCode.SIGNATURE_INVALID, transformFailure.error.code) + } + + @Test + fun metadataKeyRotationAcceptsOnlyTheCurrentOverlappingSnapshot() = runTest { + val scenario = scenario(metadataKeyId = "key-old") + val response = scenario.signedResponse(keyId = "key-new") + + val beforeRotation = assertIs(scenario.complete(response)) + assertEquals(SamlErrorCode.SIGNATURE_INVALID, beforeRotation.error.code) + + scenario.metadata.current = scenario.metadata("key-new") + assertIs>(scenario.complete(response)) + } + + @Test + fun bothPolicyRequiresAndVerifiesBothSignatures() = runTest { + val scenario = scenario(signaturePolicy = SamlSignaturePolicy.BOTH) + val failure = assertIs(scenario.complete(scenario.signedResponse())) + assertEquals(SamlErrorCode.SIGNATURE_INVALID, failure.error.code) + } + + @Test + fun ecdsaSha256UsesTheCommonIdentityCryptoBoundary() = runTest { + val scenario = scenario() + scenario.metadata.current = scenario.metadata("key-ec", es256 = true) + val response = scenario.signedResponse( + keyId = "key-ec", + signatureAlgorithm = SamlSignatureAlgorithm.ECDSA_SHA256.uri + ) + + assertIs>(scenario.complete(response)) + } + + @Test + fun disabledProviderLeaseBlocksCallbackBeforeAssertionWork() = runTest { + val scenario = scenario() + val response = scenario.signedResponse() + scenario.disableProvider() + + val failure = assertIs(scenario.complete(response)) + + assertEquals(SamlErrorCode.PROVIDER_DISABLED, failure.error.code) + assertEquals(codes.yousef.aether.auth.ChallengeState.PENDING, scenario.store.snapshot().challenges.single().state) + } + + @Test + fun disabledProviderIsRejectedBeforeMetadataResolutionOrChallengeCreation() = runTest { + val deterministic = DeterministicIdentityRuntime() + val tenantId = OrganizationId("saml-tenant") + val store = InMemoryIdentityStore( + InMemoryIdentityStoreSeed(organizations = listOf(IdentityFixtures.organization(tenantId))) + ) + val config = SamlProviderConfig( + tenantId = tenantId, + providerId = "workforce", + spEntityId = SP_ENTITY_ID, + idpEntityId = IDP_ENTITY_ID, + assertionConsumerServiceUrl = ACS_URL + ) + val storageKey = samlProviderStorageKey(config, deterministic.runtime.crypto) + assertIs>( + IdentityFederationProviderManager(store, deterministic.runtime).disableProvider( + tenantId, + FederationProviderKind.SAML, + config.providerId, + storageKey, + reasonCode = "saml_test_prestart_disabled" + ) + ) + val resolver = MutableMetadataResolver(metadata("key-old", deterministic.deterministicClock.now())) + val provider = SamlIdentityProvider(config, deterministic.runtime, store, resolver) + + val failure = assertIs(provider.beginAuthentication()) + + assertEquals(SamlErrorCode.PROVIDER_DISABLED, failure.error.code) + assertEquals(0, resolver.resolutionCount) + assertEquals(emptyList(), store.snapshot().challenges) + } + + @Test + fun disableThenReenableRejectsCallbackFromTheEarlierProviderVersion() = runTest { + val scenario = scenario() + val response = scenario.signedResponse() + scenario.disableProvider() + scenario.enableProvider() + + val failure = assertIs(scenario.complete(response)) + + assertEquals(SamlErrorCode.PROVIDER_DISABLED, failure.error.code) + assertEquals(codes.yousef.aether.auth.ChallengeState.PENDING, scenario.store.snapshot().challenges.single().state) + } + + @Test + fun enabledJitAtomicallyCreatesFreshNullEmailViewerAndNeverMergesByEmail() = runTest { + val scenario = jitScenario() + + val authenticated = assertIs>( + scenario.complete(scenario.signedResponse()) + ).value + + assertTrue(authenticated.userId != EXISTING_EMAIL_USER_ID) + val snapshot = scenario.store.snapshot() + val user = requireNotNull(snapshot.users.singleOrNull { it.id == authenticated.userId }) + assertEquals(null, user.primaryEmail) + assertEquals(MembershipState.ACTIVE, snapshot.memberships.single { it.userId == user.id }.state) + assertEquals(OrganizationRole.VIEWER, snapshot.memberships.single { it.userId == user.id }.role) + assertEquals(user.id, snapshot.externalIdentities.single().userId) + assertEquals(EmailAddress("saml-user@example.test"), snapshot.externalIdentities.single().email) + } + + @Test + fun jitReplayLinkAndProviderFailuresLeaveNoProvisionedOrphans() = runTest { + LinkFailureMode.entries.forEach { mode -> + val (scenario, interceptor) = jitFailureScenario(mode) + + val failure = assertIs( + scenario.complete(scenario.signedResponse()), + mode.name + ) + + assertEquals(mode.expectedSamlError, failure.error.code, mode.name) + val provisioning = requireNotNull(interceptor.command?.jitProvisioning) + val snapshot = scenario.store.snapshot() + assertTrue(snapshot.users.none { it.id == provisioning.user.id }, mode.name) + assertTrue(snapshot.memberships.none { it.id == provisioning.membership.id }, mode.name) + assertTrue(snapshot.externalIdentities.none { it.userId == provisioning.user.id }, mode.name) + } + } + + private suspend fun scenario( + metadataKeyId: String = "key-old", + signaturePolicy: SamlSignaturePolicy = SamlSignaturePolicy.ASSERTION_OR_RESPONSE + ): Scenario { + val deterministic = DeterministicIdentityRuntime() + val userId = UserId("saml-user") + val tenantId = OrganizationId("saml-tenant") + val store = InMemoryIdentityStore( + InMemoryIdentityStoreSeed( + users = listOf(IdentityFixtures.user(userId)), + organizations = listOf(IdentityFixtures.organization(tenantId)) + ) + ) + val config = SamlProviderConfig( + tenantId = tenantId, + providerId = "workforce", + spEntityId = SP_ENTITY_ID, + idpEntityId = IDP_ENTITY_ID, + assertionConsumerServiceUrl = ACS_URL, + signaturePolicy = signaturePolicy + ) + val initialMetadata = metadata(metadataKeyId, deterministic.deterministicClock.now()) + val resolver = MutableMetadataResolver(initialMetadata) + val provider = SamlIdentityProvider( + config = config, + runtime = deterministic.runtime, + store = store, + metadataResolver = resolver + ) + val start = assertIs>( + provider.beginAuthentication(SamlAuthenticationRequest(linkToUserId = userId)) + ).value + return Scenario(deterministic, store, config, resolver, provider, start) + } + + private suspend fun jitScenario(): Scenario { + val deterministic = DeterministicIdentityRuntime() + val existingEmailUser = IdentityFixtures.user(EXISTING_EMAIL_USER_ID).copy( + primaryEmail = EmailAddress("saml-user@example.test") + ) + val tenantId = OrganizationId("saml-tenant") + val store = InMemoryIdentityStore( + InMemoryIdentityStoreSeed( + users = listOf(existingEmailUser), + organizations = listOf(IdentityFixtures.organization(tenantId)) + ) + ) + val config = SamlProviderConfig( + tenantId = tenantId, + providerId = "workforce", + spEntityId = SP_ENTITY_ID, + idpEntityId = IDP_ENTITY_ID, + assertionConsumerServiceUrl = ACS_URL, + jitProvisioningEnabled = true + ) + val resolver = MutableMetadataResolver(metadata("key-old", deterministic.deterministicClock.now())) + val provider = SamlIdentityProvider( + config = config, + runtime = deterministic.runtime, + store = store, + metadataResolver = resolver + ) + val start = assertIs>( + provider.beginAuthentication() + ).value + return Scenario(deterministic, store, config, resolver, provider, start) + } + + private suspend fun jitFailureScenario( + mode: LinkFailureMode + ): Pair { + val deterministic = DeterministicIdentityRuntime() + val tenantId = OrganizationId("saml-tenant") + val existingEmailUser = IdentityFixtures.user(EXISTING_EMAIL_USER_ID).copy( + primaryEmail = EmailAddress("saml-user@example.test") + ) + val delegate = InMemoryIdentityStore( + InMemoryIdentityStoreSeed( + users = listOf(existingEmailUser), + organizations = listOf(IdentityFixtures.organization(tenantId)) + ) + ) + val config = SamlProviderConfig( + tenantId = tenantId, + providerId = "workforce", + spEntityId = SP_ENTITY_ID, + idpEntityId = IDP_ENTITY_ID, + assertionConsumerServiceUrl = ACS_URL, + jitProvisioningEnabled = true + ) + val interceptor = InterceptingLinkStore(delegate, deterministic, config, mode) + val resolver = MutableMetadataResolver(metadata("key-old", deterministic.deterministicClock.now())) + val provider = SamlIdentityProvider(config, deterministic.runtime, interceptor, resolver) + val start = assertIs>( + provider.beginAuthentication() + ).value + return Scenario(deterministic, delegate, config, resolver, provider, start) to interceptor + } + + private fun metadata(keyId: String, now: Instant = IdentityFixtures.baseInstant): SamlProviderMetadata = + SamlProviderMetadata( + entityId = IDP_ENTITY_ID, + redirectSsoUrl = SSO_URL, + verificationKeys = listOf( + SamlVerificationKey.Rsa( + keyId = keyId, + publicKey = RsaPublicKey(ByteArray(256) { 1 }), + validFrom = now - 1.hours, + validUntil = now + 2.hours + ) + ), + version = "metadata-$keyId", + validUntil = now + 2.hours + ) + + private class MutableMetadataResolver(var current: SamlProviderMetadata) : SamlMetadataResolver { + var resolutionCount: Int = 0 + private set + + override suspend fun resolve(): SamlProviderMetadata { + resolutionCount += 1 + return current + } + } + + private class InterceptingLinkStore( + private val delegate: InMemoryIdentityStore, + private val deterministic: DeterministicIdentityRuntime, + private val config: SamlProviderConfig, + private val mode: LinkFailureMode + ) : IdentityStore by delegate { + var command: LinkExternalIdentityCommand? = null + private set + + override suspend fun linkExternalIdentity( + command: LinkExternalIdentityCommand + ): StoreResult { + this.command = command + return when (mode) { + LinkFailureMode.REPLAY -> StoreResult.Failure( + IdentityStoreError(IdentityStoreErrorCode.REPLAY_DETECTED) + ) + LinkFailureMode.LINK_CONFLICT -> StoreResult.Failure( + IdentityStoreError(IdentityStoreErrorCode.UNIQUE_CONSTRAINT) + ) + LinkFailureMode.PROVIDER_DISABLED -> { + val disabled = IdentityFederationProviderManager(delegate, deterministic.runtime).disableProvider( + organizationId = config.tenantId, + kind = FederationProviderKind.SAML, + providerId = config.providerId, + storageKey = command.federationProviderLease.storageKey, + reasonCode = "saml_test_prelink_disabled" + ) + check(disabled is IdentityOperationResult.Success) + delegate.linkExternalIdentity(command) + } + } + } + } + + private enum class LinkFailureMode(val expectedSamlError: SamlErrorCode) { + REPLAY(SamlErrorCode.ASSERTION_REPLAYED), + LINK_CONFLICT(SamlErrorCode.EXTERNAL_IDENTITY_CONFLICT), + PROVIDER_DISABLED(SamlErrorCode.PROVIDER_DISABLED) + } + + private class Scenario( + private val deterministic: DeterministicIdentityRuntime, + val store: InMemoryIdentityStore, + private val config: SamlProviderConfig, + val metadata: MutableMetadataResolver, + private val provider: SamlIdentityProvider, + private val start: SamlAuthenticationStart + ) { + val now: Instant get() = deterministic.deterministicClock.now() + + suspend fun disableProvider() { + val storageKey = samlProviderStorageKey(config, deterministic.runtime.crypto) + assertIs>( + IdentityFederationProviderManager(store, deterministic.runtime).disableProvider( + organizationId = config.tenantId, + kind = FederationProviderKind.SAML, + providerId = config.providerId, + storageKey = storageKey, + reasonCode = "saml_test_disabled" + ) + ) + } + + suspend fun enableProvider() { + val storageKey = samlProviderStorageKey(config, deterministic.runtime.crypto) + assertIs>( + IdentityFederationProviderManager(store, deterministic.runtime).enableProvider( + organizationId = config.tenantId, + kind = FederationProviderKind.SAML, + providerId = config.providerId, + storageKey = storageKey, + reasonCode = "saml_test_enabled" + ) + ) + } + + fun metadata(keyId: String, es256: Boolean = false): SamlProviderMetadata = + SamlProviderMetadata( + entityId = IDP_ENTITY_ID, + redirectSsoUrl = SSO_URL, + verificationKeys = listOf( + if (es256) { + SamlVerificationKey.Es256( + keyId, + P256PublicKey(ByteArray(65).also { it[0] = 0x04 }), + validFrom = now - 1.hours, + validUntil = now + 2.hours + ) + } else { + SamlVerificationKey.Rsa( + keyId, + RsaPublicKey(ByteArray(256) { 2 }), + validFrom = now - 1.hours, + validUntil = now + 2.hours + ) + } + ), + version = "metadata-$keyId", + validUntil = now + 2.hours + ) + + suspend fun signedResponse( + recipient: String = ACS_URL, + expiresAt: Instant = now + 5.minutes, + keyId: String = "key-old", + extraAssertion: Boolean = false, + signatureAlgorithm: String = SamlSignatureAlgorithm.RSA_SHA256.uri, + canonicalizationTransform: String = EXCLUSIVE_C14N + ): String { + val placeholderDigest = SamlBase64.encode(ByteArray(32)) + val provisional = responseXml( + digest = placeholderDigest, + recipient = recipient, + expiresAt = expiresAt, + keyId = keyId, + extraAssertion = extraAssertion, + signatureAlgorithm = signatureAlgorithm, + canonicalizationTransform = canonicalizationTransform + ) + if (extraAssertion) return provisional + val document = BoundedSamlXml.parse( + provisional.encodeToByteArray(), + SamlXmlLimits(1_048_576, 32, 2_048, 64, 262_144) + ) + val assertion = document.root.singleDirectElement(SAML_ASSERTION_NAMESPACE, "Assertion") + val signature = assertion.singleDirectElement(XMLDSIG_NAMESPACE, "Signature") + val canonical = canonicalizeExclusive(assertion, signature) + val digest = deterministic.deterministicCrypto.sha256(canonical) + canonical.fill(0) + val finalXml = responseXml( + digest = SamlBase64.encode(digest), + recipient = recipient, + expiresAt = expiresAt, + keyId = keyId, + extraAssertion = false, + signatureAlgorithm = signatureAlgorithm, + canonicalizationTransform = canonicalizationTransform + ) + digest.fill(0) + return finalXml + } + + suspend fun complete(xml: String): SamlResult { + val relay = start.state.relayStateBytes() + val relayState = try { + Base64Url.encode(relay) + } finally { + relay.fill(0) + } + return provider.completeAuthentication( + SamlPostResponseRequest( + samlResponse = SamlBase64.encode(xml.encodeToByteArray()), + relayState = relayState, + state = start.state + ) + ) + } + + private fun responseXml( + digest: String, + recipient: String, + expiresAt: Instant, + keyId: String, + extraAssertion: Boolean, + signatureAlgorithm: String, + canonicalizationTransform: String + ): String { + val issueInstant = now.toString() + val notBefore = (now - 1.minutes).toString() + val signatureValue = SamlBase64.encode( + ByteArray( + if (signatureAlgorithm == SamlSignatureAlgorithm.ECDSA_SHA256.uri) 64 else 256 + ) { 7 } + ) + val assertion = """ + + $IDP_ENTITY_ID + + + + + + + + + + + $digest + + + $signatureValue + $keyId + + + subject-123 + + + + + + $SP_ENTITY_ID + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + saml-user@example.test + + """.trimIndent() + val secondAssertion = if (extraAssertion) { + "$IDP_ENTITY_ID" + } else { + "" + } + return """ + + $IDP_ENTITY_ID + + $assertion + $secondAssertion + + """.trimIndent() + } + } + + private companion object { + const val SP_ENTITY_ID = "https://sp.example.test/saml/metadata" + const val IDP_ENTITY_ID = "https://idp.example.test/entity" + const val ACS_URL = "https://sp.example.test/identity/v1/federation/saml/workforce" + const val SSO_URL = "https://idp.example.test/sso" + val EXISTING_EMAIL_USER_ID = UserId("saml-existing-email-user") + } +} diff --git a/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlParserHardeningTest.kt b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlParserHardeningTest.kt new file mode 100644 index 0000000..e0d7f7f --- /dev/null +++ b/aether-auth-saml/src/commonTest/kotlin/codes/yousef/aether/auth/saml/SamlParserHardeningTest.kt @@ -0,0 +1,59 @@ +package codes.yousef.aether.auth.saml + +import codes.yousef.aether.auth.OrganizationId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SamlParserHardeningTest { + @Test + fun providerConfigurationCannotExceedAuthorityWideXmlCeilings() { + assertFailsWith { providerConfig(maximumXmlDepth = 26) } + assertFailsWith { providerConfig(maximumAttributesPerElement = 31) } + + providerConfig(maximumXmlDepth = 25, maximumAttributesPerElement = 30) + } + + @Test + fun parserRejectsDepthAndAttributeExhaustionAtTheBoundary() { + val limits = SamlXmlLimits( + maximumBytes = 16_384, + maximumDepth = 25, + maximumElements = 128, + maximumAttributesPerElement = 30, + maximumTextCharacters = 4_096 + ) + val tooDeep = buildString { + repeat(26) { append("") } + repeat(26) { append("") } + } + val depthFailure = assertFailsWith { + BoundedSamlXml.parse(tooDeep.encodeToByteArray(), limits) + } + assertEquals(SamlErrorCode.RESPONSE_INVALID, depthFailure.code) + + val tooManyAttributes = buildString { + append(" append(" a").append(index).append("=\"").append(index).append('"') } + append("/>") + } + val attributeFailure = assertFailsWith { + BoundedSamlXml.parse(tooManyAttributes.encodeToByteArray(), limits) + } + assertEquals(SamlErrorCode.RESPONSE_INVALID, attributeFailure.code) + } + + private fun providerConfig( + maximumXmlDepth: Int = 25, + maximumAttributesPerElement: Int = 30 + ): SamlProviderConfig = SamlProviderConfig( + tenantId = OrganizationId("00000000-0000-7000-8000-000000000001"), + providerId = "workforce", + spEntityId = "https://sp.example.test/saml", + idpEntityId = "https://idp.example.test/saml", + assertionConsumerServiceUrl = "https://sp.example.test/identity/v1/federation/" + + "00000000-0000-7000-8000-000000000001/workforce/callback", + maximumXmlDepth = maximumXmlDepth, + maximumAttributesPerElement = maximumAttributesPerElement + ) +} diff --git a/aether-auth-scim/README.md b/aether-auth-scim/README.md new file mode 100644 index 0000000..1ed74a3 --- /dev/null +++ b/aether-auth-scim/README.md @@ -0,0 +1,50 @@ +# Aether SCIM 2.0 adapter + +`aether-auth-scim` is the optional, storage-neutral SCIM 2.0 engine for Aether identity. It runs from common Kotlin on JVM, `wasmJs`, and `wasmWasi`; it does not add an enterprise dependency to `aether-auth`. + +The fixed routes are: + +- `/scim/v2/Users` and `/scim/v2/Users/{id}` +- `/scim/v2/Groups` and `/scim/v2/Groups/{id}` + +Both resource types implement POST, GET, PUT, PATCH, and DELETE. The core User projection covers names, profile metadata, language/locale/timezone, emails, phone numbers, IM handles, photos, addresses, entitlements, informational roles, and X.509 certificate values. Password provisioning is rejected: it never creates password authentication or stores a password value. Collection GET supports one-based `startIndex`, bounded `count`, and a single equality (`eq`) filter. User filters support the identifiers and common single-/multi-value attributes; Group filters support `id`, `externalId`, `displayName`, and `members.value`. Unsupported compound filters fail with the standard `invalidFilter` response. + +## Host integration + +Construct `ScimEngine` with the shared `IdentityStore`, an implementation of `ScimDirectory`, `IdentityRuntime`, and tenant configuration. Install `ScimHttpMiddleware(engine, authenticator, authorizer, config).asMiddleware()` to expose it through Aether's framework-neutral `Exchange`/`Middleware` API on JVM, `wasmJs`, or `wasmWasi`. + +`ScimAuthenticator` and `ScimTenantAuthorizer` are mandatory and have no allow-by-default implementation. Authentication receives immutable method/path/header/connection metadata before body I/O; authorization must explicitly allow the exact `organizationId` configured on both the middleware and engine. Rejection fails closed with a generic SCIM 401/403 response, while authenticator, authorizer, handler, and infrastructure failures return a generic 503 without exception text. + +Mutating requests require one valid `Idempotency-Key` by default (the header name is configurable), `application/scim+json`, and a body within the configured limit. The adapter checks a single `Content-Length` before reading, checks the observed size afterward, rejects duplicate security/conditional/idempotency headers, strictly percent-decodes one value per query parameter, and forwards only `If-Match`, `If-None-Match`, and bounded User-Agent metadata to the engine. Server adapters must enforce the same body limit while streaming so an untrusted peer cannot force an oversized allocation before `Request.bodyBytes()` returns. Engine response status, headers, and bytes are copied unchanged to the Aether response. + +Setting `ScimConfig.enabled=false` immediately makes every endpoint unavailable. Every request also verifies that its configured organization is active, and durable reservations are re-validated against the tenant-scoped provider key before retry, preventing operation-ID collisions from crossing tenants. + +Every POST, PUT, PATCH, and DELETE needs a stable `ScimOperationId`. HTTP integrations should derive it from an allowlisted, bounded idempotency header or from a durable provider delivery identifier. Reusing an operation ID with a different method, path, version precondition, or byte-for-byte body returns a conflict. The module writes the exact `ApplyScimBatchCommand` into a durable reservation, then applies it once through `IdentityStore.applyScimBatch`; a crash and retry therefore submits the identical Group aggregate, ordered mutations, revocations, receipts, and audit events. + +`ScimDirectory.reserveOperation` is a high-level atomic command, not a database transaction callback. An implementation must validate the expected projection version, acquire a durable per-resource/uniqueness reservation, and enforce tenant uniqueness before the identity mutation runs. `completeOperation` then updates the projection and marks the operation complete atomically. Active User `userName` and `externalId`, and active Group `displayName` and `externalId`, are tenant-unique. Tombstones are retained for retry receipts but excluded from reads and uniqueness checks. + +The engine requires `If-Match` for PUT, PATCH, and DELETE by default, emits weak ETags in the HTTP header and `meta.version`, supports `If-None-Match` on resource GET, returns `201` plus `Location` for create, and returns `204` for delete. All responses use `application/scim+json`. + +## Security and tenant semantics + +Input is rejected before typed decoding when it exceeds the byte, nesting, node, or string limits. The common parser rejects malformed UTF-8, duplicate object keys (including escaped aliases), invalid Unicode surrogate pairs, non-finite numbers, and trailing data. Typed decoding rejects unknown writable attributes; the defined read-only `id`, `meta`, and User `groups` fields are accepted and ignored as required by RFC 7644. + +The provider key stored in core mutations is structurally scoped as `{providerName}:{organizationId}`. New SCIM users receive a new stable identity and a Viewer membership; email is never used to merge identities. Explicit Group mappings can raise the tenant role. Unknown groups persist as SCIM groups but grant no role, User `roles` and `entitlements` are informational only, and removing the final mapped group returns the membership to Viewer. + +Setting `active=false` or deleting a User removes only that organization's membership. It does not deactivate or delete the stable `User`. In the same identity-store transaction, deprovisioning and role changes revoke active federated sessions carrying that organization and active device-token families bound to that user and organization, including their active access and refresh tokens. They do not advance the stable User's global session epoch, revoke passkey/global sessions, or affect another tenant's membership, sessions, or token families. + +Errors contain only fixed generic text and standard SCIM fields. Internal exceptions, provider subjects, emails, tokens, and storage diagnostics are never serialized. + +## Atomicity boundary + +`IdentityStore.applyScimBatch` atomically commits all affected Users and Memberships, the canonical `ScimGroup` aggregate, final-state last-owner validation, tenant-local credential revocation, child audits, the canonical `scim.group_changed` audit, and an outer idempotency receipt. An identical retry returns the stored receipt; reusing the operation ID with any changed command field is an idempotency conflict. This closes the former per-member partial-application and sink-delivery gap. + +The module-owned RFC projection may be deployed separately from the identity store and therefore cannot share its physical transaction. `ScimDirectory.reserveOperation` remains the durable recovery boundary: it freezes the exact identity batch before the identity commit, and `completeOperation` applies that already-reserved projection after the batch succeeds. Retrying either side is deterministic and idempotent. + +## Verification + +```text +./gradlew :aether-auth-scim:allTests +``` + +The common suite runs the bounded parser, equality filters, pagination, Users and Groups CRUD/PATCH, ETag failures, idempotent retry, unknown-group behavior, atomic multi-member role changes, tenant-only deprovisioning, credential isolation, Group audit/aggregate persistence, and tombstones on all configured targets. diff --git a/aether-auth-scim/build.gradle.kts b/aether-auth-scim/build.gradle.kts new file mode 100644 index 0000000..7528d32 --- /dev/null +++ b/aether-auth-scim/build.gradle.kts @@ -0,0 +1,24 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm { compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } + wasmJs { nodejs() } + wasmWasi { nodejs() } + sourceSets { + commonMain.dependencies { + api(project(":aether-auth")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(project(":aether-auth-testkit")) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/aether-auth-scim/gradle.lockfile b/aether-auth-scim/gradle.lockfile new file mode 100644 index 0000000..d014f7e --- /dev/null +++ b/aether-auth-scim/gradle.lockfile @@ -0,0 +1,93 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.fasterxml.jackson.core:jackson-core:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.16.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.netty:netty-buffer:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport:4.1.115.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +junit:junit:4.13.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-wasi:2.3.21=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit:2.3.21=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-wasi:2.3.21=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.26.1=wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu-wasm-wasi:0.30.0-beta=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,wasmWasiMainResolvableDependenciesMetadata,wasmWasiTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmWasiCompileClasspath,wasmWasiTestCompileClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-wasi:1.10.2=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-wasi:1.10.2=wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-wasi:0.7.1=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-wasi:1.9.0=wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath,wasmWasiRuntimeClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-wasi:1.9.0=wasmWasiCompileClasspath,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,wasmWasiCompileClasspath,wasmWasiMainResolvableDependenciesMetadata,wasmWasiRuntimeClasspath,wasmWasiTestCompileClasspath,wasmWasiTestResolvableDependenciesMetadata,wasmWasiTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinCompilerPluginClasspathWasmWasiMain,kotlinCompilerPluginClasspathWasmWasiTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,wasmWasiMainImplementationDependenciesMetadata,wasmWasiTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/BoundedScimJson.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/BoundedScimJson.kt new file mode 100644 index 0000000..47e0726 --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/BoundedScimJson.kt @@ -0,0 +1,235 @@ +package codes.yousef.aether.auth.scim + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +data class ScimJsonLimits( + val maximumBytes: Int = 256 * 1024, + val maximumDepth: Int = 20, + val maximumNodes: Int = 10_000, + val maximumStringCharacters: Int = 16_384 +) { + init { + require(maximumBytes in 1..2 * 1024 * 1024) + require(maximumDepth in 1..100) + require(maximumNodes in 1..100_000) + require(maximumStringCharacters in 1..256 * 1024) + } +} + +/** + * Parses a size-capped UTF-8 JSON document without relying on an unbounded object mapper. + * Duplicate member names, excessive nesting/nodes/strings, invalid Unicode, and trailing data are + * rejected before typed SCIM decoding. + */ +class BoundedScimJson( + private val limits: ScimJsonLimits = ScimJsonLimits() +) { + private val typedJson = Json { + ignoreUnknownKeys = false + explicitNulls = false + encodeDefaults = false + isLenient = false + allowSpecialFloatingPointValues = false + } + + fun parse(bytes: ByteArray): JsonElement { + if (bytes.isEmpty() || bytes.size > limits.maximumBytes) throw ScimJsonException("invalid_size") + val source = try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: Throwable) { + throw ScimJsonException("invalid_utf8") + } + return Parser(source, limits).parse() + } + + fun decode(bytes: ByteArray, deserializer: DeserializationStrategy): T { + val element = parse(bytes) + return try { + typedJson.decodeFromJsonElement(deserializer, element) + } catch (_: SerializationException) { + throw ScimJsonException("invalid_document") + } catch (_: IllegalArgumentException) { + throw ScimJsonException("invalid_value") + } + } + + private class Parser(private val source: String, private val limits: ScimJsonLimits) { + private var index = 0 + private var nodes = 0 + + fun parse(): JsonElement { + skipWhitespace() + val value = value(1) + skipWhitespace() + if (index != source.length) fail("trailing_data") + return value + } + + private fun value(depth: Int): JsonElement { + if (depth > limits.maximumDepth) fail("too_deep") + if (++nodes > limits.maximumNodes) fail("too_many_nodes") + if (index >= source.length) fail("unexpected_end") + return when (source[index]) { + '{' -> objectValue(depth) + '[' -> arrayValue(depth) + '"' -> JsonPrimitive(stringValue()) + 't' -> literal("true", JsonPrimitive(true)) + 'f' -> literal("false", JsonPrimitive(false)) + 'n' -> literal("null", JsonNull) + '-', in '0'..'9' -> numberValue() + else -> fail("invalid_token") + } + } + + private fun objectValue(depth: Int): JsonObject { + index++ + skipWhitespace() + val fields = linkedMapOf() + if (consume('}')) return JsonObject(fields) + while (true) { + if (index >= source.length || source[index] != '"') fail("invalid_object_key") + val key = stringValue() + if (fields.containsKey(key)) fail("duplicate_key") + skipWhitespace() + expect(':') + skipWhitespace() + fields[key] = value(depth + 1) + skipWhitespace() + if (consume('}')) return JsonObject(fields) + expect(',') + skipWhitespace() + } + } + + private fun arrayValue(depth: Int): JsonArray { + index++ + skipWhitespace() + val values = mutableListOf() + if (consume(']')) return JsonArray(values) + while (true) { + values += value(depth + 1) + skipWhitespace() + if (consume(']')) return JsonArray(values) + expect(',') + skipWhitespace() + } + } + + private fun stringValue(): String { + expect('"') + val result = StringBuilder() + while (index < source.length) { + val char = source[index++] + when { + char == '"' -> return result.toString() + char == '\\' -> result.append(escape()) + char.code < 0x20 -> fail("control_character") + char.isHighSurrogate() -> { + if (index >= source.length || !source[index].isLowSurrogate()) fail("invalid_surrogate") + result.append(char) + result.append(source[index++]) + } + char.isLowSurrogate() -> fail("invalid_surrogate") + else -> result.append(char) + } + if (result.length > limits.maximumStringCharacters) fail("string_too_long") + } + fail("unterminated_string") + } + + private fun escape(): String { + if (index >= source.length) fail("unterminated_escape") + return when (val escaped = source[index++]) { + '"', '\\', '/' -> escaped.toString() + 'b' -> "\b" + 'f' -> "\u000c" + 'n' -> "\n" + 'r' -> "\r" + 't' -> "\t" + 'u' -> unicodeEscape() + else -> fail("invalid_escape") + } + } + + private fun unicodeEscape(): String { + val first = readHexCodeUnit() + if (first.isLowSurrogate()) fail("invalid_surrogate") + if (!first.isHighSurrogate()) return first.toString() + if (index + 2 > source.length || source[index] != '\\' || source[index + 1] != 'u') { + fail("invalid_surrogate") + } + index += 2 + val second = readHexCodeUnit() + if (!second.isLowSurrogate()) fail("invalid_surrogate") + return "$first$second" + } + + private fun readHexCodeUnit(): Char { + if (index + 4 > source.length) fail("invalid_unicode_escape") + var value = 0 + repeat(4) { + val digit = source[index++].digitToIntOrNull(16) ?: fail("invalid_unicode_escape") + value = (value shl 4) or digit + } + return value.toChar() + } + + private fun numberValue(): JsonPrimitive { + val start = index + if (consume('-') && index >= source.length) fail("invalid_number") + if (consume('0')) { + if (index < source.length && source[index].isDigit()) fail("invalid_number") + } else { + if (index >= source.length || source[index] !in '1'..'9') fail("invalid_number") + while (index < source.length && source[index].isDigit()) index++ + } + if (consume('.')) { + if (index >= source.length || !source[index].isDigit()) fail("invalid_number") + while (index < source.length && source[index].isDigit()) index++ + } + if (index < source.length && (source[index] == 'e' || source[index] == 'E')) { + index++ + if (index < source.length && (source[index] == '+' || source[index] == '-')) index++ + if (index >= source.length || !source[index].isDigit()) fail("invalid_number") + while (index < source.length && source[index].isDigit()) index++ + } + val encoded = source.substring(start, index) + encoded.toLongOrNull()?.let { return JsonPrimitive(it) } + val number = encoded.toDoubleOrNull()?.takeIf { it.isFinite() } ?: fail("invalid_number") + return JsonPrimitive(number) + } + + private fun literal(text: String, value: T): T { + if (!source.startsWith(text, index)) fail("invalid_literal") + index += text.length + return value + } + + private fun skipWhitespace() { + while (index < source.length && source[index] in setOf(' ', '\t', '\r', '\n')) index++ + } + + private fun consume(expected: Char): Boolean { + if (index < source.length && source[index] == expected) { + index++ + return true + } + return false + } + + private fun expect(expected: Char) { + if (!consume(expected)) fail("expected_$expected") + } + + private fun fail(code: String): Nothing = throw ScimJsonException(code) + } +} + +class ScimJsonException internal constructor(internal val safeCode: String) : IllegalArgumentException("Invalid SCIM JSON") diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimDirectory.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimDirectory.kt new file mode 100644 index 0000000..e6efe03 --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimDirectory.kt @@ -0,0 +1,168 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.ApplyScimBatchCommand +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.ScimOperationId +import codes.yousef.aether.auth.UserId +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ScimUserRecord( + val id: String, + val organizationId: OrganizationId, + val identityUserId: UserId, + val membershipId: MembershipId, + val externalId: String? = null, + val userName: String, + val name: ScimName? = null, + val displayName: String? = null, + val nickName: String? = null, + val profileUrl: String? = null, + val title: String? = null, + val userType: String? = null, + val preferredLanguage: String? = null, + val locale: String? = null, + val timezone: String? = null, + val active: Boolean, + val emails: List = emptyList(), + val phoneNumbers: List = emptyList(), + val ims: List = emptyList(), + val photos: List = emptyList(), + val addresses: List = emptyList(), + val entitlements: List = emptyList(), + val roles: List = emptyList(), + val x509Certificates: List = emptyList(), + val version: Long, + val createdAt: Instant, + val updatedAt: Instant, + val deletedAt: Instant? = null +) { + init { + require(Regex("[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}").matches(id)) { "Invalid SCIM User ID" } + require(userName.isNotBlank() && userName.length <= 320) + require(externalId == null || (externalId.isNotBlank() && externalId.length <= 1_024)) + require(emails.size <= 20 && addresses.size <= 20) + require(listOf(phoneNumbers, ims, photos, entitlements, roles, x509Certificates).all { it.size <= 100 }) + require(version >= 1) + require(updatedAt >= createdAt) + require(deletedAt == null || deletedAt >= createdAt) + } + + val deleted: Boolean get() = deletedAt != null +} + +@Serializable +data class ScimGroupRecord( + val id: String, + val organizationId: OrganizationId, + val externalId: String? = null, + val displayName: String, + val memberUserResourceIds: Set = emptySet(), + val version: Long, + val createdAt: Instant, + val updatedAt: Instant, + val deletedAt: Instant? = null +) { + init { + require(Regex("[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}").matches(id)) { "Invalid SCIM Group ID" } + require(displayName.isNotBlank() && displayName.length <= 200) + require(memberUserResourceIds.size <= 5_000) + require(memberUserResourceIds.all { Regex("[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}").matches(it) }) + require(version >= 1) + require(updatedAt >= createdAt) + require(deletedAt == null || deletedAt >= createdAt) + } + + val deleted: Boolean get() = deletedAt != null +} + +@Serializable +enum class ScimResourceKind { + @SerialName("user") USER, + @SerialName("group") GROUP +} + +/** + * Durable operation journal entry. A directory must return the original reservation when the same + * operation ID and fingerprint are retried, or an idempotency conflict when the fingerprint differs. + * Persisting the exact identity batch makes a retry byte-for-byte identical even if the first + * attempt reached IdentityStore but crashed before the SCIM projection was committed. + */ +@Serializable +data class ScimOperationReservation( + val operationId: ScimOperationId, + val fingerprint: String, + val kind: ScimResourceKind, + val resourceId: String, + val expectedProjectionVersion: Long? = null, + val desiredUser: ScimUserRecord? = null, + val desiredGroup: ScimGroupRecord? = null, + val identityBatch: ApplyScimBatchCommand, + val reservedAt: Instant +) { + init { + require(fingerprint.isNotBlank() && fingerprint.length <= 128) + require(Regex("[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}").matches(resourceId)) + require(expectedProjectionVersion == null || expectedProjectionVersion >= 1) + require(identityBatch.operationId == operationId) + when (kind) { + ScimResourceKind.USER -> require(desiredUser != null && desiredGroup == null) + ScimResourceKind.GROUP -> require(desiredGroup != null && desiredUser == null) + } + require((desiredUser?.id ?: desiredGroup?.id) == resourceId) + } +} + +@Serializable +data class ScimDirectoryCommit( + val user: ScimUserRecord? = null, + val group: ScimGroupRecord? = null, + val alreadyCompleted: Boolean = false +) { + init { require((user == null) != (group == null)) } +} + +enum class ScimDirectoryErrorCode { + NOT_FOUND, + ALREADY_EXISTS, + UNIQUENESS_CONFLICT, + VERSION_CONFLICT, + IDEMPOTENCY_CONFLICT, + UNAVAILABLE, + INTERNAL +} + +data class ScimDirectoryError( + val code: ScimDirectoryErrorCode, + val retryable: Boolean = code == ScimDirectoryErrorCode.UNAVAILABLE +) + +sealed interface ScimDirectoryResult { + data class Success(val value: T) : ScimDirectoryResult + data class Failure(val error: ScimDirectoryError) : ScimDirectoryResult +} + +/** + * Tenant-scoped SCIM projection and durable operation journal. + * + * Implementations enforce uniqueness of active `(organizationId, userName)`, User externalId, + * Group displayName, and Group externalId values. [reserveOperation] atomically validates the + * expected projection version and uniqueness keys and acquires a durable per-resource reservation + * before any identity mutation can run. [completeOperation] applies the already-reserved desired + * record/tombstone and marks the reservation complete. This ordering prevents a projection CAS or + * uniqueness failure after IdentityStore has committed. Deleted records are retained as tombstones + * for idempotency but omitted from reads. + */ +interface ScimDirectory { + suspend fun findUser(organizationId: OrganizationId, id: String): ScimDirectoryResult + suspend fun listUsers(organizationId: OrganizationId): ScimDirectoryResult> + suspend fun findGroup(organizationId: OrganizationId, id: String): ScimDirectoryResult + suspend fun listGroups(organizationId: OrganizationId): ScimDirectoryResult> + + suspend fun findOperation(operationId: ScimOperationId): ScimDirectoryResult + suspend fun reserveOperation(reservation: ScimOperationReservation): ScimDirectoryResult + suspend fun completeOperation(operationId: ScimOperationId): ScimDirectoryResult +} diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimEngine.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimEngine.kt new file mode 100644 index 0000000..7896f5e --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimEngine.kt @@ -0,0 +1,1170 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.ApplyScimBatchCommand +import codes.yousef.aether.auth.ApplyScimMutationCommand +import codes.yousef.aether.auth.AuditAction +import codes.yousef.aether.auth.AuditActor +import codes.yousef.aether.auth.AuditActorType +import codes.yousef.aether.auth.AuditEvent +import codes.yousef.aether.auth.AuditOutcome +import codes.yousef.aether.auth.AuditRequestMetadata +import codes.yousef.aether.auth.AuditTarget +import codes.yousef.aether.auth.AuditTargetType +import codes.yousef.aether.auth.Base64Url +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.ExternalSubject +import codes.yousef.aether.auth.IdentityIdFactory +import codes.yousef.aether.auth.IdentityRuntime +import codes.yousef.aether.auth.IdentityStore +import codes.yousef.aether.auth.IdentityStoreError +import codes.yousef.aether.auth.IdentityStoreErrorCode +import codes.yousef.aether.auth.Membership +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.OrganizationState +import codes.yousef.aether.auth.ScimMutation +import codes.yousef.aether.auth.ScimMutationType +import codes.yousef.aether.auth.ScimOperationId +import codes.yousef.aether.auth.ScimGroup +import codes.yousef.aether.auth.ScimGroupState +import codes.yousef.aether.auth.ScimTenantRevocation +import codes.yousef.aether.auth.StoreResult +import codes.yousef.aether.auth.User +import codes.yousef.aether.auth.UserState +import kotlin.time.Instant +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +data class ScimConfig( + val organizationId: OrganizationId, + val providerName: String, + val scimBaseUrl: String, + val groupRoleMappings: Map = emptyMap(), + val maximumPageSize: Int = 1_000, + val jsonLimits: ScimJsonLimits = ScimJsonLimits(), + val requireVersionPreconditions: Boolean = true, + val enabled: Boolean = true +) { + init { + require(Regex("[a-z0-9][a-z0-9._-]{0,119}").matches(providerName)) { "Invalid SCIM provider name" } + require(scimBaseUrl == scimBaseUrl.trim() && '?' !in scimBaseUrl && '#' !in scimBaseUrl) + val separator = scimBaseUrl.indexOf("://") + require(separator > 0) + val scheme = scimBaseUrl.substring(0, separator) + val authority = scimBaseUrl.substring(separator + 3).substringBefore('/') + require(authority.isNotBlank() && '@' !in authority && authority.none(Char::isWhitespace)) + val loopback = authority == "localhost" || authority.startsWith("localhost:") || + authority == "127.0.0.1" || authority.startsWith("127.0.0.1:") || + authority == "[::1]" || authority.startsWith("[::1]:") + require(scimBaseUrl.endsWith("/scim/v2") && (scheme == "https" || (scheme == "http" && loopback))) { + "SCIM base URL must be HTTPS or loopback HTTP and end in /scim/v2" + } + require(maximumPageSize in 1..5_000) + require(groupRoleMappings.keys.all { it.isNotBlank() && it.length <= 1_024 }) + require(OrganizationRole.OWNER !in groupRoleMappings.values) { + "SCIM group mappings cannot grant organization ownership" + } + } + + internal val tenantProviderKey: String = "$providerName:${organizationId.value}" + + internal fun roleFor(group: ScimGroupRecord): OrganizationRole? = + (group.externalId?.let(groupRoleMappings::get) ?: groupRoleMappings[group.id]) + ?.takeUnless { it == OrganizationRole.OWNER } +} + +/** + * Storage-neutral RFC 7643/7644 Users and Groups engine for the fixed `/scim/v2` surface. + * Authentication and routing integration remain host concerns; all protocol bodies and errors are + * generated here identically on JVM, wasmJs, and wasmWasi. + */ +class ScimEngine( + private val identityStore: IdentityStore, + private val directory: ScimDirectory, + private val runtime: IdentityRuntime, + private val config: ScimConfig +) { + internal val configuredOrganizationId: OrganizationId = config.organizationId + private val ids = IdentityIdFactory(runtime) + private val boundedJson = BoundedScimJson(config.jsonLimits) + private val json = Json { + ignoreUnknownKeys = false + explicitNulls = false + // SCIM requires `schemas` on every resource/message; its default must always be emitted. + encodeDefaults = true + isLenient = false + } + + suspend fun handle(request: ScimRequest): ScimResponse = try { + handleValidated(request) + } catch (failure: ScimJsonException) { + val type = if (failure.safeCode in setOf("invalid_value", "invalid_document")) { + ScimErrorType.INVALID_VALUE + } else { + ScimErrorType.INVALID_SYNTAX + } + error(400, type, "The SCIM JSON document is invalid.", request) + } catch (failure: ScimPatchException) { + error(400, failure.type, "The SCIM PATCH operation is invalid.", request) + } catch (_: ScimFilterException) { + error(400, ScimErrorType.INVALID_FILTER, "The SCIM filter is invalid.", request) + } catch (_: ScimQueryException) { + error(400, ScimErrorType.INVALID_VALUE, "The SCIM query is invalid.", request) + } catch (failure: DirectoryReadFailure) { + directoryError(failure.directoryError, request) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: IllegalArgumentException) { + error(400, ScimErrorType.INVALID_VALUE, "The SCIM request contains an invalid value.", request) + } catch (_: Exception) { + error(503, null, "The SCIM service is unavailable.", request) + } + + private suspend fun handleValidated(request: ScimRequest): ScimResponse { + if (request.bodyBytes().size > config.jsonLimits.maximumBytes) { + return error(413, ScimErrorType.TOO_MANY, "The SCIM request is too large.", request) + } + if (!config.enabled) return notFound(request) + when (val organization = identityStore.findOrganization(config.organizationId)) { + is StoreResult.Failure -> return storeError(organization.error, request) + is StoreResult.Success -> if (organization.value?.state != OrganizationState.ACTIVE) return notFound(request) + } + val mutating = request.method != ScimHttpMethod.GET + val fingerprint = if (mutating) operationFingerprint(request) else null + if (mutating) { + val operationId = request.operationId + ?: return error(400, ScimErrorType.INVALID_VALUE, "A stable operation ID is required.", request) + when (val existing = directory.findOperation(operationId)) { + is ScimDirectoryResult.Failure -> return directoryError(existing.error, request) + is ScimDirectoryResult.Success -> existing.value?.let { reservation -> + if (!reservationBelongsToTenant(reservation)) return notFound(request) + if (reservation.fingerprint != fingerprint) { + return error(409, ScimErrorType.UNIQUENESS, "The operation ID is already in use.", request) + } + return finishReserved(reservation, request) + } + } + } + + return when (request.path) { + "/scim/v2/Users" -> usersCollection(request, fingerprint) + "/scim/v2/Groups" -> groupsCollection(request, fingerprint) + else -> when { + request.path.startsWith("/scim/v2/Users/") -> { + val id = resourceId(request.path, "/scim/v2/Users/") + ?: return error(404, null, "The SCIM resource was not found.", request) + userResource(request, id, fingerprint) + } + request.path.startsWith("/scim/v2/Groups/") -> { + val id = resourceId(request.path, "/scim/v2/Groups/") + ?: return error(404, null, "The SCIM resource was not found.", request) + groupResource(request, id, fingerprint) + } + else -> error(404, null, "The SCIM resource was not found.", request) + } + } + } + + private suspend fun usersCollection(request: ScimRequest, fingerprint: String?): ScimResponse = when (request.method) { + ScimHttpMethod.GET -> listUsers(request) + ScimHttpMethod.POST -> if (request.query.isEmpty()) { + createUser(request, requireNotNull(fingerprint)) + } else { + error(400, ScimErrorType.INVALID_VALUE, "The SCIM query is invalid.", request) + } + else -> methodNotAllowed(request, "GET, POST") + } + + private suspend fun groupsCollection(request: ScimRequest, fingerprint: String?): ScimResponse = when (request.method) { + ScimHttpMethod.GET -> listGroups(request) + ScimHttpMethod.POST -> if (request.query.isEmpty()) { + createGroup(request, requireNotNull(fingerprint)) + } else { + error(400, ScimErrorType.INVALID_VALUE, "The SCIM query is invalid.", request) + } + else -> methodNotAllowed(request, "GET, POST") + } + + private suspend fun userResource(request: ScimRequest, id: String, fingerprint: String?): ScimResponse = + if (request.query.isNotEmpty()) { + error(400, ScimErrorType.INVALID_VALUE, "The SCIM query is invalid.", request) + } else when (request.method) { + ScimHttpMethod.GET -> getUser(request, id) + ScimHttpMethod.PUT -> replaceUser(request, id, requireNotNull(fingerprint)) + ScimHttpMethod.PATCH -> patchUser(request, id, requireNotNull(fingerprint)) + ScimHttpMethod.DELETE -> deleteUser(request, id, requireNotNull(fingerprint)) + ScimHttpMethod.POST -> methodNotAllowed(request, "GET, PUT, PATCH, DELETE") + } + + private suspend fun groupResource(request: ScimRequest, id: String, fingerprint: String?): ScimResponse = + if (request.query.isNotEmpty()) { + error(400, ScimErrorType.INVALID_VALUE, "The SCIM query is invalid.", request) + } else when (request.method) { + ScimHttpMethod.GET -> getGroup(request, id) + ScimHttpMethod.PUT -> replaceGroup(request, id, requireNotNull(fingerprint)) + ScimHttpMethod.PATCH -> patchGroup(request, id, requireNotNull(fingerprint)) + ScimHttpMethod.DELETE -> deleteGroup(request, id, requireNotNull(fingerprint)) + ScimHttpMethod.POST -> methodNotAllowed(request, "GET, PUT, PATCH, DELETE") + } + + private suspend fun listUsers(request: ScimRequest): ScimResponse { + val records = when (val result = directory.listUsers(config.organizationId)) { + is ScimDirectoryResult.Success -> result.value.filterNot { it.deleted } + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } + val filter = request.query["filter"]?.let(ScimFilterParser::parse) + val filtered = records.filter { filter == null || userMatches(it, filter) }.sortedBy { it.id } + val page = parsePage(request.query, config.maximumPageSize) + val resources = page.apply(filtered).map { userResource(it, userGroups(it.id)) } + return jsonResponse( + 200, + ScimUserListResponse( + totalResults = filtered.size, + startIndex = page.startIndex, + itemsPerPage = resources.size, + resources = resources + ), + ScimUserListResponse.serializer(), + request = request + ) + } + + private suspend fun listGroups(request: ScimRequest): ScimResponse { + val records = when (val result = directory.listGroups(config.organizationId)) { + is ScimDirectoryResult.Success -> result.value.filterNot { it.deleted } + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } + val filter = request.query["filter"]?.let(ScimFilterParser::parse) + val filtered = records.filter { filter == null || groupMatches(it, filter) }.sortedBy { it.id } + val page = parsePage(request.query, config.maximumPageSize) + val resources = page.apply(filtered).map(::groupResource) + return jsonResponse( + 200, + ScimGroupListResponse( + totalResults = filtered.size, + startIndex = page.startIndex, + itemsPerPage = resources.size, + resources = resources + ), + ScimGroupListResponse.serializer(), + request = request + ) + } + + private suspend fun getUser(request: ScimRequest, id: String): ScimResponse { + val record = readUser(id, request) ?: return notFound(request) + val etag = weakEtag(record.version) + if (request.header("if-none-match") in setOf("*", etag)) return emptyResponse(304, request) + return userResponse(200, record, request) + } + + private suspend fun getGroup(request: ScimRequest, id: String): ScimResponse { + val record = readGroup(id, request) ?: return notFound(request) + val etag = weakEtag(record.version) + if (request.header("if-none-match") in setOf("*", etag)) return emptyResponse(304, request) + return groupResponse(200, record, request) + } + + private suspend fun createUser(request: ScimRequest, fingerprint: String): ScimResponse { + val document = boundedJson.decode(request.bodyBytes(), ScimUserDocument.serializer()) + val now = runtime.clock.now() + val userId = ids.newUserId() + val membershipId = ids.newMembershipId() + val record = ScimUserRecord( + id = userId.value, + organizationId = config.organizationId, + identityUserId = userId, + membershipId = membershipId, + externalId = document.externalId, + userName = document.userName, + name = document.name, + displayName = document.displayName, + nickName = document.nickName, + profileUrl = document.profileUrl, + title = document.title, + userType = document.userType, + preferredLanguage = document.preferredLanguage, + locale = document.locale, + timezone = document.timezone, + active = document.active, + emails = document.emails, + phoneNumbers = document.phoneNumbers, + ims = document.ims, + photos = document.photos, + addresses = document.addresses, + entitlements = document.entitlements, + roles = document.roles, + x509Certificates = document.x509Certificates, + version = 1, + createdAt = now, + updatedAt = now + ) + val user = User( + id = userId, + state = UserState.ACTIVE, + displayName = profileDisplayName(document), + primaryEmail = primaryEmail(document.emails), + avatarUrl = primaryValue(document.photos), + locale = document.locale ?: document.preferredLanguage, + timeZone = document.timezone, + createdAt = now, + updatedAt = now, + activatedAt = now + ) + val membership = Membership( + id = membershipId, + organizationId = config.organizationId, + userId = userId, + role = OrganizationRole.VIEWER, + state = if (document.active) MembershipState.ACTIVE else MembershipState.REMOVED, + createdAt = now, + updatedAt = now, + removedAt = if (document.active) null else now + ) + val commands = listOf( + mutationCommand(request, user, null, ScimMutationType.UPSERT_USER, record.externalSubject(), now), + mutationCommand( + request, + null, + membership, + if (document.active) ScimMutationType.UPSERT_MEMBERSHIP else ScimMutationType.REMOVE_MEMBERSHIP, + record.externalSubject(), + now + ) + ) + val revocations = if (document.active) emptyList() else listOf( + revocation(userId, "scim_membership_deactivated") + ) + val reservation = ScimOperationReservation( + operationId = requireNotNull(request.operationId), + fingerprint = fingerprint, + kind = ScimResourceKind.USER, + resourceId = record.id, + desiredUser = record, + identityBatch = batchCommand(request, commands, revocations, userId = userId), + reservedAt = now + ) + return reserveAndFinish(reservation, request) + } + + private suspend fun replaceUser(request: ScimRequest, id: String, fingerprint: String): ScimResponse { + val existing = readUser(id, request) ?: return notFound(request) + precondition(existing.version, request)?.let { return it } + val document = boundedJson.decode(request.bodyBytes(), ScimUserDocument.serializer()) + return updateUser(request, existing, document, fingerprint) + } + + private suspend fun patchUser(request: ScimRequest, id: String, fingerprint: String): ScimResponse { + val existing = readUser(id, request) ?: return notFound(request) + precondition(existing.version, request)?.let { return it } + val patch = boundedJson.decode(request.bodyBytes(), ScimPatchRequest.serializer()) + val document = ScimPatchApplicator.user(existing, patch) + return updateUser(request, existing, document, fingerprint) + } + + private suspend fun updateUser( + request: ScimRequest, + existing: ScimUserRecord, + document: ScimUserDocument, + fingerprint: String + ): ScimResponse { + val now = runtime.clock.now() + val identityUser = when (val result = identityStore.findUser(existing.identityUserId)) { + is StoreResult.Success -> result.value + is StoreResult.Failure -> return storeError(result.error, request) + } ?: return error(503, null, "The identity store is unavailable.", request) + val currentMembership = when ( + val result = identityStore.findMembershipForUser(existing.identityUserId, config.organizationId) + ) { + is StoreResult.Success -> result.value + is StoreResult.Failure -> return storeError(result.error, request) + } + val desiredRole = if (document.active) roleForUser(existing.id) else { + currentMembership?.role ?: OrganizationRole.VIEWER + } + val desiredMembership = if (currentMembership == null) { + Membership( + id = existing.membershipId, + organizationId = config.organizationId, + userId = existing.identityUserId, + role = desiredRole, + state = if (document.active) MembershipState.ACTIVE else MembershipState.REMOVED, + createdAt = now, + updatedAt = now, + removedAt = if (document.active) null else now + ) + } else { + currentMembership.copy( + role = desiredRole, + state = if (document.active) MembershipState.ACTIVE else MembershipState.REMOVED, + version = currentMembership.version + 1, + updatedAt = now, + removedAt = if (document.active) null else now + ) + } + val membership = desiredMembership.takeIf { + currentMembership == null || currentMembership.role != it.role || currentMembership.state != it.state + } + val desiredDisplayName = profileDisplayName(document) + val desiredPrimaryEmail = primaryEmail(document.emails) + val desiredAvatarUrl = primaryValue(document.photos) + val desiredLocale = document.locale ?: document.preferredLanguage + val updatedUser = if ( + identityUser.displayName != desiredDisplayName || identityUser.primaryEmail != desiredPrimaryEmail || + identityUser.avatarUrl != desiredAvatarUrl || identityUser.locale != desiredLocale || + identityUser.timeZone != document.timezone + ) { + identityUser.copy( + displayName = desiredDisplayName, + primaryEmail = desiredPrimaryEmail, + avatarUrl = desiredAvatarUrl, + locale = desiredLocale, + timeZone = document.timezone, + version = identityUser.version + 1, + updatedAt = now + ) + } else null + val record = existing.copy( + externalId = document.externalId, + userName = document.userName, + name = document.name, + displayName = document.displayName, + nickName = document.nickName, + profileUrl = document.profileUrl, + title = document.title, + userType = document.userType, + preferredLanguage = document.preferredLanguage, + locale = document.locale, + timezone = document.timezone, + active = document.active, + emails = document.emails, + phoneNumbers = document.phoneNumbers, + ims = document.ims, + photos = document.photos, + addresses = document.addresses, + entitlements = document.entitlements, + roles = document.roles, + x509Certificates = document.x509Certificates, + version = existing.version + 1, + updatedAt = now + ) + val commands = buildList { + updatedUser?.let { + add(mutationCommand(request, it, null, ScimMutationType.UPSERT_USER, record.externalSubject(), now)) + } + membership?.let { + add(mutationCommand( + request, + null, + it, + if (document.active) ScimMutationType.UPSERT_MEMBERSHIP else ScimMutationType.REMOVE_MEMBERSHIP, + record.externalSubject(), + now + )) + } + } + val privilegeChanged = currentMembership != null && membership != null + val revocations = if (privilegeChanged) listOf( + revocation(existing.identityUserId, "scim_membership_changed") + ) else emptyList() + val reservation = ScimOperationReservation( + operationId = requireNotNull(request.operationId), + fingerprint = fingerprint, + kind = ScimResourceKind.USER, + resourceId = existing.id, + expectedProjectionVersion = existing.version, + desiredUser = record, + identityBatch = batchCommand( + request, + commands, + revocations, + userId = existing.identityUserId + ), + reservedAt = now + ) + return reserveAndFinish(reservation, request) + } + + private suspend fun deleteUser(request: ScimRequest, id: String, fingerprint: String): ScimResponse { + val existing = readUser(id, request) ?: return notFound(request) + precondition(existing.version, request)?.let { return it } + val membership = when ( + val result = identityStore.findMembershipForUser(existing.identityUserId, config.organizationId) + ) { + is StoreResult.Success -> result.value + is StoreResult.Failure -> return storeError(result.error, request) + } + val now = runtime.clock.now() + val commands = if (membership == null || membership.state == MembershipState.REMOVED) emptyList() else listOf( + mutationCommand( + request = request, + user = null, + membership = membership.copy( + state = MembershipState.REMOVED, + version = membership.version + 1, + updatedAt = now, + removedAt = now + ), + type = ScimMutationType.REMOVE_MEMBERSHIP, + subject = existing.externalSubject(), + at = now + ) + ) + val reservation = ScimOperationReservation( + operationId = requireNotNull(request.operationId), + fingerprint = fingerprint, + kind = ScimResourceKind.USER, + resourceId = existing.id, + expectedProjectionVersion = existing.version, + desiredUser = existing.copy( + active = false, + version = existing.version + 1, + updatedAt = now, + deletedAt = now + ), + identityBatch = batchCommand( + request, + commands, + listOf(revocation(existing.identityUserId, "scim_user_deleted")), + userId = existing.identityUserId + ), + reservedAt = now + ) + return reserveAndFinish(reservation, request) + } + + private suspend fun createGroup(request: ScimRequest, fingerprint: String): ScimResponse { + val document = boundedJson.decode(request.bodyBytes(), ScimGroupDocument.serializer()) + validateMembers(document.members, request)?.let { return it } + val now = runtime.clock.now() + val record = ScimGroupRecord( + id = ids.newScimOperationId().value, + organizationId = config.organizationId, + externalId = document.externalId, + displayName = document.displayName, + memberUserResourceIds = document.members.mapTo(linkedSetOf()) { it.value }, + version = 1, + createdAt = now, + updatedAt = now + ) + return writeGroup(request, old = null, desired = record, fingerprint = fingerprint) + } + + private suspend fun replaceGroup(request: ScimRequest, id: String, fingerprint: String): ScimResponse { + val existing = readGroup(id, request) ?: return notFound(request) + precondition(existing.version, request)?.let { return it } + val document = boundedJson.decode(request.bodyBytes(), ScimGroupDocument.serializer()) + validateMembers(document.members, request)?.let { return it } + val desired = existing.copy( + externalId = document.externalId, + displayName = document.displayName, + memberUserResourceIds = document.members.mapTo(linkedSetOf()) { it.value }, + version = existing.version + 1, + updatedAt = runtime.clock.now() + ) + return writeGroup(request, existing, desired, fingerprint) + } + + private suspend fun patchGroup(request: ScimRequest, id: String, fingerprint: String): ScimResponse { + val existing = readGroup(id, request) ?: return notFound(request) + precondition(existing.version, request)?.let { return it } + val patch = boundedJson.decode(request.bodyBytes(), ScimPatchRequest.serializer()) + val document = ScimPatchApplicator.group(existing, patch) + validateMembers(document.members, request)?.let { return it } + val desired = existing.copy( + externalId = document.externalId, + displayName = document.displayName, + memberUserResourceIds = document.members.mapTo(linkedSetOf()) { it.value }, + version = existing.version + 1, + updatedAt = runtime.clock.now() + ) + return writeGroup(request, existing, desired, fingerprint) + } + + private suspend fun deleteGroup(request: ScimRequest, id: String, fingerprint: String): ScimResponse { + val existing = readGroup(id, request) ?: return notFound(request) + precondition(existing.version, request)?.let { return it } + val now = runtime.clock.now() + return writeGroup( + request, + old = existing, + desired = existing.copy( + memberUserResourceIds = emptySet(), + version = existing.version + 1, + updatedAt = now, + deletedAt = now + ), + fingerprint = fingerprint + ) + } + + private suspend fun writeGroup( + request: ScimRequest, + old: ScimGroupRecord?, + desired: ScimGroupRecord, + fingerprint: String + ): ScimResponse { + val currentGroups = when (val result = directory.listGroups(config.organizationId)) { + is ScimDirectoryResult.Success -> result.value.filterNot { it.deleted } + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } + val prospectiveGroups = currentGroups.filterNot { it.id == desired.id }.toMutableList().apply { + if (!desired.deleted) add(desired) + } + val impacted = ((old?.memberUserResourceIds ?: emptySet()) + desired.memberUserResourceIds).sorted() + val now = desired.updatedAt + val commands = mutableListOf() + val revocations = mutableListOf() + val desiredIdentityMemberIds = linkedSetOf() + for (resourceId in impacted) { + val userRecord = when (val result = directory.findUser(config.organizationId, resourceId)) { + is ScimDirectoryResult.Success -> result.value + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } ?: continue + if (resourceId in desired.memberUserResourceIds) { + desiredIdentityMemberIds += userRecord.identityUserId + } + val existingMembership = when ( + val result = identityStore.findMembershipForUser(userRecord.identityUserId, config.organizationId) + ) { + is StoreResult.Success -> result.value + is StoreResult.Failure -> return storeError(result.error, request) + } + val shouldBeActive = userRecord.active && !userRecord.deleted + val desiredRole = highestRole(prospectiveGroups.mapNotNull { group -> + if (resourceId in group.memberUserResourceIds) config.roleFor(group) else null + }) ?: OrganizationRole.VIEWER + val replacement = when { + existingMembership == null && shouldBeActive -> Membership( + id = userRecord.membershipId, + organizationId = config.organizationId, + userId = userRecord.identityUserId, + role = desiredRole, + createdAt = now, + updatedAt = now + ) + existingMembership == null -> null + !shouldBeActive && existingMembership.state != MembershipState.REMOVED -> existingMembership.copy( + state = MembershipState.REMOVED, + version = existingMembership.version + 1, + updatedAt = now, + removedAt = now + ) + shouldBeActive && (existingMembership.state != MembershipState.ACTIVE || existingMembership.role != desiredRole) -> + existingMembership.copy( + role = desiredRole, + state = MembershipState.ACTIVE, + version = existingMembership.version + 1, + updatedAt = now, + removedAt = null + ) + else -> null + } + if (replacement != null) { + val type = if (replacement.state == MembershipState.REMOVED) { + ScimMutationType.REMOVE_MEMBERSHIP + } else { + ScimMutationType.UPSERT_MEMBERSHIP + } + val command = mutationCommand( + request, + null, + replacement, + type, + userRecord.externalSubject(), + now + ) + commands += command + if (existingMembership != null) { + revocations += revocation( + userRecord.identityUserId, + "scim_group_role_changed" + ) + } + } + } + val reservation = ScimOperationReservation( + operationId = requireNotNull(request.operationId), + fingerprint = fingerprint, + kind = ScimResourceKind.GROUP, + resourceId = desired.id, + expectedProjectionVersion = old?.version, + desiredGroup = desired, + identityBatch = batchCommand( + request = request, + mutations = commands, + revocations = revocations, + group = desired, + groupMemberUserIds = desiredIdentityMemberIds + ), + reservedAt = now + ) + return reserveAndFinish(reservation, request) + } + + private suspend fun validateMembers(members: List, request: ScimRequest): ScimResponse? { + for (member in members) { + val record = when (val result = directory.findUser(config.organizationId, member.value)) { + is ScimDirectoryResult.Success -> result.value + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } + if (record == null || record.deleted) { + return error(400, ScimErrorType.INVALID_VALUE, "A SCIM group member is invalid.", request) + } + } + return null + } + + private suspend fun reserveAndFinish( + reservation: ScimOperationReservation, + request: ScimRequest + ): ScimResponse { + val reserved = when (val result = directory.reserveOperation(reservation)) { + is ScimDirectoryResult.Success -> result.value + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } + if (!reservationBelongsToTenant(reserved)) return notFound(request) + if (reserved.fingerprint != reservation.fingerprint) { + return error(409, ScimErrorType.UNIQUENESS, "The operation ID is already in use.", request) + } + return finishReserved(reserved, request) + } + + private suspend fun finishReserved( + reservation: ScimOperationReservation, + request: ScimRequest + ): ScimResponse { + when (val result = identityStore.applyScimBatch(reservation.identityBatch)) { + is StoreResult.Success -> Unit + is StoreResult.Failure -> return storeError(result.error, request) + } + val commit = when (val result = directory.completeOperation(reservation.operationId)) { + is ScimDirectoryResult.Success -> result.value + is ScimDirectoryResult.Failure -> return directoryError(result.error, request) + } + val projectionMatches = when (reservation.kind) { + ScimResourceKind.USER -> commit.user == reservation.desiredUser && commit.group == null + ScimResourceKind.GROUP -> commit.group == reservation.desiredGroup && commit.user == null + } + if (!projectionMatches) return error(503, null, "The SCIM service is unavailable.", request) + return when { + commit.user?.deleted == true || commit.group?.deleted == true -> emptyResponse(204, request) + commit.user != null -> userResponse( + if (reservation.expectedProjectionVersion == null) 201 else 200, + commit.user, + request + ) + commit.group != null -> groupResponse( + if (reservation.expectedProjectionVersion == null) 201 else 200, + commit.group, + request + ) + else -> error(503, null, "The SCIM service is unavailable.", request) + } + } + + private suspend fun userResponse(status: Int, record: ScimUserRecord, request: ScimRequest): ScimResponse { + val resource = userResource(record, userGroups(record.id)) + return jsonResponse( + status, + resource, + ScimUserResource.serializer(), + headers = mapOf("ETag" to weakEtag(record.version), "Location" to resource.meta.location), + request = request + ) + } + + private fun groupResponse(status: Int, record: ScimGroupRecord, request: ScimRequest): ScimResponse { + val resource = groupResource(record) + return jsonResponse( + status, + resource, + ScimGroupResource.serializer(), + headers = mapOf("ETag" to weakEtag(record.version), "Location" to resource.meta.location), + request = request + ) + } + + private fun userResource(record: ScimUserRecord, groups: List): ScimUserResource = ScimUserResource( + id = record.id, + externalId = record.externalId, + userName = record.userName, + name = record.name, + displayName = record.displayName, + nickName = record.nickName, + profileUrl = record.profileUrl, + title = record.title, + userType = record.userType, + preferredLanguage = record.preferredLanguage, + locale = record.locale, + timezone = record.timezone, + active = record.active, + emails = record.emails, + phoneNumbers = record.phoneNumbers, + ims = record.ims, + photos = record.photos, + addresses = record.addresses, + entitlements = record.entitlements, + roles = record.roles, + x509Certificates = record.x509Certificates, + groups = groups, + meta = ScimMeta( + resourceType = "User", + created = record.createdAt, + lastModified = record.updatedAt, + location = "${config.scimBaseUrl}/Users/${record.id}", + version = weakEtag(record.version) + ) + ) + + private fun groupResource(record: ScimGroupRecord): ScimGroupResource = ScimGroupResource( + id = record.id, + externalId = record.externalId, + displayName = record.displayName, + members = record.memberUserResourceIds.sorted().map { id -> + ScimMember(value = id, reference = "${config.scimBaseUrl}/Users/$id", type = "User") + }, + meta = ScimMeta( + resourceType = "Group", + created = record.createdAt, + lastModified = record.updatedAt, + location = "${config.scimBaseUrl}/Groups/${record.id}", + version = weakEtag(record.version) + ) + ) + + private suspend fun userGroups(userResourceId: String): List { + val groups = when (val result = directory.listGroups(config.organizationId)) { + is ScimDirectoryResult.Success -> result.value + is ScimDirectoryResult.Failure -> throw DirectoryReadFailure(result.error) + } + return groups.filter { !it.deleted && userResourceId in it.memberUserResourceIds } + .sortedBy { it.id } + .map { group -> + ScimMember( + value = group.id, + display = group.displayName, + reference = "${config.scimBaseUrl}/Groups/${group.id}", + type = "direct" + ) + } + } + + private suspend fun roleForUser(userResourceId: String): OrganizationRole { + val groups = when (val result = directory.listGroups(config.organizationId)) { + is ScimDirectoryResult.Success -> result.value + is ScimDirectoryResult.Failure -> throw DirectoryReadFailure(result.error) + } + return highestRole(groups.filter { !it.deleted && userResourceId in it.memberUserResourceIds } + .mapNotNull(config::roleFor)) ?: OrganizationRole.VIEWER + } + + private fun highestRole(roles: List): OrganizationRole? = when { + OrganizationRole.ADMIN in roles -> OrganizationRole.ADMIN + OrganizationRole.PUBLISHER in roles -> OrganizationRole.PUBLISHER + OrganizationRole.VIEWER in roles -> OrganizationRole.VIEWER + else -> null + } + + private suspend fun readUser(id: String, request: ScimRequest): ScimUserRecord? = + when (val result = directory.findUser(config.organizationId, id)) { + is ScimDirectoryResult.Success -> result.value?.takeUnless { it.deleted } + is ScimDirectoryResult.Failure -> throw DirectoryReadFailure(result.error) + } + + private suspend fun readGroup(id: String, request: ScimRequest): ScimGroupRecord? = + when (val result = directory.findGroup(config.organizationId, id)) { + is ScimDirectoryResult.Success -> result.value?.takeUnless { it.deleted } + is ScimDirectoryResult.Failure -> throw DirectoryReadFailure(result.error) + } + + private fun precondition(version: Long, request: ScimRequest): ScimResponse? { + val supplied = request.header("if-match") + if (supplied == null && config.requireVersionPreconditions) { + return error(412, null, "A current resource version is required.", request) + } + if (supplied != null && supplied != "*" && supplied != weakEtag(version)) { + return error(412, null, "The resource version does not match.", request) + } + return null + } + + private fun mutationCommand( + request: ScimRequest, + user: User?, + membership: Membership?, + type: ScimMutationType, + subject: ExternalSubject, + at: Instant + ): ApplyScimMutationCommand { + val target = if (membership != null) { + AuditTarget(AuditTargetType.MEMBERSHIP, membership.id.value) + } else { + AuditTarget(AuditTargetType.USER, requireNotNull(user).id.value) + } + val audit = AuditEvent( + id = ids.newAuditEventId(), + actor = AuditActor(AuditActorType.SYSTEM), + organizationId = config.organizationId, + action = AuditAction.SCIM_MUTATION_APPLIED, + target = target, + outcome = AuditOutcome.SUCCEEDED, + reasonCode = type.name.lowercase(), + request = request.requestId?.let { id -> + AuditRequestMetadata( + requestId = id, + method = request.method.name, + path = request.path + ) + }, + occurredAt = at + ) + return ApplyScimMutationCommand( + mutation = ScimMutation( + operationId = ids.newScimOperationId(), + provider = config.tenantProviderKey, + type = type, + externalSubject = subject, + user = user, + membership = membership, + occurredAt = at + ), + auditEvent = audit + ) + } + + private fun batchCommand( + request: ScimRequest, + mutations: List, + revocations: List, + userId: codes.yousef.aether.auth.UserId? = null, + group: ScimGroupRecord? = null, + groupMemberUserIds: Set = emptySet() + ): ApplyScimBatchCommand { + require((userId == null) != (group == null)) { "SCIM batch must target one User or Group" } + val groupAggregate = group?.let { record -> + ScimGroup( + id = record.id, + organizationId = config.organizationId, + provider = config.tenantProviderKey, + externalId = record.externalId, + displayName = record.displayName, + memberUserIds = groupMemberUserIds.sortedBy { it.value }.toCollection(linkedSetOf()), + state = if (record.deleted) ScimGroupState.DELETED else ScimGroupState.ACTIVE, + version = record.version, + createdAt = record.createdAt, + updatedAt = record.updatedAt, + deletedAt = record.deletedAt + ) + } + val target = if (groupAggregate != null) { + AuditTarget(AuditTargetType.SCIM_GROUP, groupAggregate.id) + } else { + AuditTarget(AuditTargetType.USER, requireNotNull(userId).value) + } + val audit = AuditEvent( + id = ids.newAuditEventId(), + actor = AuditActor(AuditActorType.SYSTEM), + organizationId = config.organizationId, + action = if (groupAggregate != null) AuditAction.SCIM_GROUP_CHANGED else AuditAction.SCIM_MUTATION_APPLIED, + target = target, + outcome = AuditOutcome.SUCCEEDED, + reasonCode = if (groupAggregate != null) { + if (groupAggregate.state == ScimGroupState.DELETED) "scim_group_deleted" else "scim_group_changed" + } else { + "scim_user_changed" + }, + request = request.requestId?.let { id -> + AuditRequestMetadata( + requestId = id, + method = request.method.name, + path = request.path + ) + }, + occurredAt = groupAggregate?.updatedAt ?: mutations.firstOrNull()?.mutation?.occurredAt ?: runtime.clock.now() + ) + return ApplyScimBatchCommand( + operationId = requireNotNull(request.operationId), + organizationId = config.organizationId, + provider = config.tenantProviderKey, + mutations = mutations, + group = groupAggregate, + expectedGroupVersion = groupAggregate?.version?.minus(1L), + revocations = revocations, + auditEvent = audit + ) + } + + private fun revocation(userId: codes.yousef.aether.auth.UserId, reason: String) = + ScimTenantRevocation( + userId = userId, + reasonCode = reason + ) + + private fun ScimUserRecord.externalSubject(): ExternalSubject = ExternalSubject(externalId ?: id) + + private fun profileDisplayName(document: ScimUserDocument): String = + document.displayName ?: document.name?.formatted ?: document.userName + + private fun primaryEmail(emails: List): EmailAddress? = + (emails.firstOrNull { it.primary } ?: emails.firstOrNull())?.value?.let(::EmailAddress) + + private fun primaryValue(values: List): String? = + (values.firstOrNull { it.primary } ?: values.firstOrNull())?.value + + private fun userMatches(record: ScimUserRecord, filter: ScimEqualityFilter): Boolean = when (filter.attributePath) { + "id" -> record.id == filter.value + "externalid" -> record.externalId == filter.value + "username" -> record.userName.equals(filter.value, ignoreCase = true) + "displayname" -> record.displayName?.equals(filter.value, ignoreCase = true) == true + "nickname" -> record.nickName?.equals(filter.value, ignoreCase = true) == true + "title" -> record.title?.equals(filter.value, ignoreCase = true) == true + "usertype" -> record.userType?.equals(filter.value, ignoreCase = true) == true + "active" -> record.active == filter.value.toBooleanStrictOrNull() + "emails.value" -> record.emails.any { it.value.equals(filter.value, ignoreCase = true) } + "phonenumbers.value" -> record.phoneNumbers.any { it.value == filter.value } + "ims.value" -> record.ims.any { it.value.equals(filter.value, ignoreCase = true) } + "entitlements.value" -> record.entitlements.any { it.value == filter.value } + "roles.value" -> record.roles.any { it.value == filter.value } + else -> throw ScimFilterException() + } + + private fun groupMatches(record: ScimGroupRecord, filter: ScimEqualityFilter): Boolean = when (filter.attributePath) { + "id" -> record.id == filter.value + "externalid" -> record.externalId == filter.value + "displayname" -> record.displayName.equals(filter.value, ignoreCase = true) + "members.value" -> filter.value in record.memberUserResourceIds + else -> throw ScimFilterException() + } + + private suspend fun operationFingerprint(request: ScimRequest): String { + val prefix = buildString { + append(request.method.name) + append('\n') + append(request.path) + append('\n') + append(request.header("if-match") ?: "") + append('\n') + request.query.toList().sortedBy { it.first }.forEach { (name, value) -> + append(name) + append('=') + append(value) + append('\n') + } + }.encodeToByteArray() + return Base64Url.encode(runtime.crypto.sha256(prefix + request.bodyBytes())) + } + + private fun resourceId(path: String, prefix: String): String? { + val id = path.removePrefix(prefix) + return id.takeIf { path.startsWith(prefix) && Regex("[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}").matches(it) } + } + + private fun reservationBelongsToTenant(reservation: ScimOperationReservation): Boolean { + val desiredOrganizationId = reservation.desiredUser?.organizationId ?: reservation.desiredGroup?.organizationId + if (desiredOrganizationId != config.organizationId) return false + val batch = reservation.identityBatch + return batch.operationId == reservation.operationId && + batch.organizationId == config.organizationId && + batch.provider == config.tenantProviderKey + } + + private fun jsonResponse( + status: Int, + value: T, + serializer: SerializationStrategy, + headers: Map = emptyMap(), + request: ScimRequest + ): ScimResponse = ScimResponse( + status = status, + headers = responseHeaders(request) + mapOf("Content-Type" to "application/scim+json") + headers, + body = json.encodeToString(serializer, value).encodeToByteArray() + ) + + private fun error( + status: Int, + type: ScimErrorType?, + detail: String, + request: ScimRequest + ): ScimResponse = jsonResponse( + status, + ScimErrorResponse(status = status.toString(), scimType = type?.wireName, detail = detail), + ScimErrorResponse.serializer(), + request = request + ) + + private fun notFound(request: ScimRequest): ScimResponse = + error(404, null, "The SCIM resource was not found.", request) + + private fun methodNotAllowed(request: ScimRequest, allow: String): ScimResponse = ScimResponse( + status = 405, + headers = responseHeaders(request) + mapOf("Allow" to allow, "Content-Type" to "application/scim+json"), + body = json.encodeToString( + ScimErrorResponse.serializer(), + ScimErrorResponse(status = "405", detail = "The SCIM method is not allowed.") + ).encodeToByteArray() + ) + + private fun emptyResponse(status: Int, request: ScimRequest): ScimResponse = + ScimResponse(status, responseHeaders(request)) + + private fun responseHeaders(request: ScimRequest): Map = + request.requestId?.let { mapOf("X-Request-Id" to it) } ?: emptyMap() + + private fun storeError(error: IdentityStoreError, request: ScimRequest): ScimResponse = when (error.code) { + IdentityStoreErrorCode.NOT_FOUND -> notFound(request) + IdentityStoreErrorCode.ALREADY_EXISTS, + IdentityStoreErrorCode.UNIQUE_CONSTRAINT -> error(409, ScimErrorType.UNIQUENESS, "The SCIM resource is not unique.", request) + IdentityStoreErrorCode.VERSION_CONFLICT -> error( + 412, + null, + "The resource version does not match.", + request + ) + IdentityStoreErrorCode.LAST_OWNER, + IdentityStoreErrorCode.INVALID_TRANSITION, + IdentityStoreErrorCode.IDEMPOTENCY_CONFLICT -> error( + 409, + ScimErrorType.INVALID_VALUE, + "The SCIM change conflicts with current state.", + request + ) + IdentityStoreErrorCode.UNAVAILABLE, + IdentityStoreErrorCode.INTERNAL -> error(503, null, "The SCIM service is unavailable.", request) + else -> error(409, ScimErrorType.INVALID_VALUE, "The SCIM change conflicts with current state.", request) + } + + private fun directoryError(error: ScimDirectoryError, request: ScimRequest): ScimResponse = when (error.code) { + ScimDirectoryErrorCode.NOT_FOUND -> notFound(request) + ScimDirectoryErrorCode.ALREADY_EXISTS, + ScimDirectoryErrorCode.UNIQUENESS_CONFLICT -> error( + 409, + ScimErrorType.UNIQUENESS, + "The SCIM resource is not unique.", + request + ) + ScimDirectoryErrorCode.VERSION_CONFLICT -> error( + 412, + null, + "The resource version does not match.", + request + ) + ScimDirectoryErrorCode.IDEMPOTENCY_CONFLICT -> error( + 409, + ScimErrorType.UNIQUENESS, + "The operation ID is already in use.", + request + ) + ScimDirectoryErrorCode.UNAVAILABLE, + ScimDirectoryErrorCode.INTERNAL -> error(503, null, "The SCIM service is unavailable.", request) + } + + /** Converted to a safe response at the outer boundary without serializing the failure. */ + private class DirectoryReadFailure( + val directoryError: ScimDirectoryError + ) : IllegalArgumentException("SCIM directory read failed") +} diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimFilter.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimFilter.kt new file mode 100644 index 0000000..bef138f --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimFilter.kt @@ -0,0 +1,53 @@ +package codes.yousef.aether.auth.scim + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +data class ScimEqualityFilter(val attributePath: String, val value: String) + +object ScimFilterParser { + private val expression = Regex( + pattern = "^([A-Za-z][A-Za-z0-9.:-]{0,199})\\s+eq\\s+(\"(?:[^\"\\\\]|\\\\.)*\"|true|false)$", + option = RegexOption.IGNORE_CASE + ) + private val stringJson = Json { isLenient = false } + + fun parse(value: String): ScimEqualityFilter { + if (value.length > 2_048) throw ScimFilterException() + val match = expression.matchEntire(value.trim()) ?: throw ScimFilterException() + val raw = match.groupValues[2] + val decoded = if (raw.startsWith('"')) { + try { + stringJson.parseToJsonElement(raw).jsonPrimitive.content + } catch (_: Throwable) { + throw ScimFilterException() + } + } else { + raw.lowercase() + } + if (decoded.length > 1_024) throw ScimFilterException() + return ScimEqualityFilter(match.groupValues[1].lowercase(), decoded) + } +} + +class ScimFilterException : IllegalArgumentException("Invalid SCIM filter") + +internal data class ScimPage(val startIndex: Int, val count: Int) { + fun apply(values: List): List { + if (count == 0) return emptyList() + val offset = (startIndex - 1).coerceAtMost(values.size) + return values.drop(offset).take(count) + } +} + +internal fun parsePage(query: Map, maximumPageSize: Int): ScimPage { + val unknown = query.keys - setOf("filter", "startIndex", "count") + if (unknown.isNotEmpty()) throw ScimQueryException() + val start = query["startIndex"]?.toIntOrNull() ?: 1 + val requested = query["count"]?.toIntOrNull() ?: minOf(100, maximumPageSize) + if (start < 1 || requested < 0) throw ScimQueryException() + return ScimPage(start, minOf(requested, maximumPageSize)) +} + +internal class ScimQueryException : IllegalArgumentException("Invalid SCIM query") diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimHttpMiddleware.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimHttpMiddleware.kt new file mode 100644 index 0000000..4f84c28 --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimHttpMiddleware.kt @@ -0,0 +1,364 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.ScimOperationId +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.Headers +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.RequestConnection +import codes.yousef.aether.core.pipeline.Middleware +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val SCIM_HTTP_HEADER_NAME = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}") + +/** Safe identity established for a SCIM client. Authentication material is never retained here. */ +class ScimClientPrincipal(val subject: String) { + init { require(subject.isNotBlank() && subject.length <= 255) { "SCIM client subject must be bounded" } } + + override fun equals(other: Any?): Boolean = other is ScimClientPrincipal && subject == other.subject + override fun hashCode(): Int = subject.hashCode() + override fun toString(): String = "ScimClientPrincipal(subject=)" +} + +/** Immutable request metadata presented to the injected authenticator before the body is read. */ +class ScimAuthenticationRequest( + val method: HttpMethod, + val path: String, + val headers: Headers, + val connection: RequestConnection +) { + override fun toString(): String = + "ScimAuthenticationRequest(method=$method, path=$path, headers=, connection=$connection)" +} + +sealed interface ScimAuthenticationResult { + data class Authenticated(val principal: ScimClientPrincipal) : ScimAuthenticationResult + data object Rejected : ScimAuthenticationResult + data object Unavailable : ScimAuthenticationResult +} + +fun interface ScimAuthenticator { + suspend fun authenticate(request: ScimAuthenticationRequest): ScimAuthenticationResult +} + +enum class ScimAuthorizationDecision { ALLOW, DENY, UNAVAILABLE } + +fun interface ScimTenantAuthorizer { + suspend fun authorize( + principal: ScimClientPrincipal, + organizationId: OrganizationId + ): ScimAuthorizationDecision +} + +fun interface ScimRequestHandler { + suspend fun handle(request: ScimRequest): ScimResponse +} + +data class ScimHttpMiddlewareConfig( + val organizationId: OrganizationId, + val maximumBodyBytes: Int = 1_048_576, + val operationIdHeader: String = "Idempotency-Key", + val requestIdHeader: String = "X-Request-ID" +) { + init { + require(maximumBodyBytes in 1_024..4_194_304) { "SCIM body limit must be 1 KiB..4 MiB" } + require(SCIM_HTTP_HEADER_NAME.matches(operationIdHeader)) { "Invalid SCIM operation-ID header" } + require(SCIM_HTTP_HEADER_NAME.matches(requestIdHeader)) { "Invalid SCIM request-ID header" } + require(!operationIdHeader.equals(requestIdHeader, ignoreCase = true)) { + "SCIM operation-ID and request-ID headers must differ" + } + } +} + +/** + * Framework-neutral `/scim/v2` transport adapter. + * + * Authentication and tenant authorization are mandatory constructor dependencies. They run before + * request-body I/O. Unknown non-SCIM paths fall through; every path under `/scim/v2` is handled and + * never reaches the application pipeline unauthenticated. + */ +class ScimHttpMiddleware internal constructor( + private val handler: ScimRequestHandler, + private val authenticator: ScimAuthenticator, + private val authorizer: ScimTenantAuthorizer, + private val config: ScimHttpMiddlewareConfig +) { + constructor( + engine: ScimEngine, + authenticator: ScimAuthenticator, + authorizer: ScimTenantAuthorizer, + config: ScimHttpMiddlewareConfig + ) : this(ScimRequestHandler(engine::handle), authenticator, authorizer, config) { + require(engine.configuredOrganizationId == config.organizationId) { + "SCIM middleware and engine must use the same organization" + } + } + + fun asMiddleware(): Middleware = middleware@{ exchange, next -> + if (!isScimPath(exchange.request.path)) { + next() + return@middleware + } + + try { + if (exchange.request.headers.getAll("Authorization").size > 1) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request is invalid.") + return@middleware + } + val authentication = authenticator.authenticate( + ScimAuthenticationRequest( + method = exchange.request.method, + path = exchange.request.path, + headers = exchange.request.headers, + connection = exchange.request.connection + ) + ) + val principal = when (authentication) { + is ScimAuthenticationResult.Authenticated -> authentication.principal + ScimAuthenticationResult.Rejected -> { + respondError(exchange, 401, null, "SCIM client authentication is required.") + return@middleware + } + ScimAuthenticationResult.Unavailable -> { + unavailable(exchange) + return@middleware + } + } + when (authorizer.authorize(principal, config.organizationId)) { + ScimAuthorizationDecision.ALLOW -> Unit + ScimAuthorizationDecision.DENY -> { + respondError(exchange, 403, null, "The SCIM client is not authorized for this tenant.") + return@middleware + } + ScimAuthorizationDecision.UNAVAILABLE -> { + unavailable(exchange) + return@middleware + } + } + + val mapped = mapRequest(exchange) + if (mapped == null) return@middleware + writeResponse(exchange, handler.handle(mapped)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + unavailable(exchange) + } + } + + private suspend fun mapRequest(exchange: Exchange): ScimRequest? = try { + mapRequestValidated(exchange) + } catch (_: InvalidTransportRequest) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request is invalid.") + null + } + + private suspend fun mapRequestValidated(exchange: Exchange): ScimRequest? { + if (exchange.request.path.length > 2_048 || '?' in exchange.request.path || '#' in exchange.request.path) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request is invalid.") + return null + } + val method = when (exchange.request.method) { + HttpMethod.GET -> ScimHttpMethod.GET + HttpMethod.POST -> ScimHttpMethod.POST + HttpMethod.PUT -> ScimHttpMethod.PUT + HttpMethod.PATCH -> ScimHttpMethod.PATCH + HttpMethod.DELETE -> ScimHttpMethod.DELETE + else -> { + respondError(exchange, 405, null, "The SCIM method is not supported.") + return null + } + } + val requestId = singleHeader(exchange, config.requestIdHeader) + if (requestId != null && !SAFE_REQUEST_ID.matches(requestId)) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request is invalid.") + return null + } + val operationValue = singleHeader(exchange, config.operationIdHeader) + val mutating = method != ScimHttpMethod.GET + if (mutating && operationValue == null) { + respondError(exchange, 400, ScimErrorType.INVALID_VALUE, "A stable operation ID is required.", requestId) + return null + } + val operationId = operationValue?.let { + ScimOperationId.parseOrNull(it) ?: run { + respondError(exchange, 400, ScimErrorType.INVALID_VALUE, "The SCIM operation ID is invalid.", requestId) + return null + } + } + + val ifMatch = singleHeader(exchange, "If-Match") + val ifNoneMatch = singleHeader(exchange, "If-None-Match") + val userAgent = singleHeader(exchange, "User-Agent") + val contentType = singleHeader(exchange, "Content-Type") + val declaredLength = singleHeader(exchange, "Content-Length") + if ((method == ScimHttpMethod.GET && ifMatch != null) || + (method != ScimHttpMethod.GET && ifNoneMatch != null) || + (method == ScimHttpMethod.POST && ifMatch != null) + ) { + respondError(exchange, 400, ScimErrorType.INVALID_VALUE, "The SCIM conditional headers are invalid.", requestId) + return null + } + val needsJsonBody = method == ScimHttpMethod.POST || method == ScimHttpMethod.PUT || method == ScimHttpMethod.PATCH + if (needsJsonBody && !isScimJsonContentType(contentType)) { + respondError(exchange, 415, ScimErrorType.INVALID_SYNTAX, "SCIM requests require application/scim+json.", requestId) + return null + } + val length = declaredLength?.let { value -> + if (value.isEmpty() || value.any { !it.isDigit() }) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request is invalid.", requestId) + return null + } + value.toLongOrNull() ?: run { + respondError(exchange, 413, ScimErrorType.TOO_MANY, "The SCIM request is too large.", requestId) + return null + } + } + if (length != null && length > config.maximumBodyBytes) { + respondError(exchange, 413, ScimErrorType.TOO_MANY, "The SCIM request is too large.", requestId) + return null + } + val body = exchange.request.bodyBytes() + if (body.size > config.maximumBodyBytes) { + respondError(exchange, 413, ScimErrorType.TOO_MANY, "The SCIM request is too large.", requestId) + return null + } + if (length != null && length != body.size.toLong()) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request is invalid.", requestId) + return null + } + if (!needsJsonBody && body.isNotEmpty()) { + respondError(exchange, 400, ScimErrorType.INVALID_SYNTAX, "The SCIM request body is invalid.", requestId) + return null + } + val query = try { + parseQuery(exchange.request.query) + } catch (_: IllegalArgumentException) { + respondError(exchange, 400, ScimErrorType.INVALID_VALUE, "The SCIM query is invalid.", requestId) + return null + } + val headers = buildMap { + ifMatch?.let { put("If-Match", it) } + ifNoneMatch?.let { put("If-None-Match", it) } + userAgent?.let { put("User-Agent", it) } + } + return ScimRequest( + method = method, + path = exchange.request.path, + query = query, + headers = headers, + body = body, + operationId = operationId, + requestId = requestId + ) + } + + private fun singleHeader(exchange: Exchange, name: String): String? { + val values = exchange.request.headers.getAll(name) + if (values.size > 1) throw InvalidTransportRequest() + val value = values.singleOrNull() + if (value?.let { it.length > 8_192 || it.any(::isUnsafeHeaderCharacter) } == true) { + throw InvalidTransportRequest() + } + return value + } + + private suspend fun writeResponse(exchange: Exchange, response: ScimResponse) { + exchange.response.statusCode = response.status + response.headers.forEach { (name, value) -> exchange.response.setHeader(name, value) } + val body = response.bodyBytes() + if (body.isNotEmpty()) exchange.response.write(body) + exchange.response.end() + } + + private suspend fun unavailable(exchange: Exchange) { + respondError(exchange, 503, null, "The SCIM service is unavailable.") + } + + private suspend fun respondError( + exchange: Exchange, + status: Int, + type: ScimErrorType?, + detail: String, + requestId: String? = null + ) { + val body = ERROR_JSON.encodeToString( + ScimErrorResponse( + status = status.toString(), + scimType = type?.wireName, + detail = detail + ) + ).encodeToByteArray() + exchange.response.statusCode = status + exchange.response.setHeader("Content-Type", "application/scim+json") + requestId?.let { exchange.response.setHeader(config.requestIdHeader, it) } + exchange.response.write(body) + exchange.response.end() + } + + private fun isScimPath(path: String): Boolean = path == SCIM_BASE || path.startsWith("$SCIM_BASE/") + + private fun isScimJsonContentType(value: String?): Boolean { + if (value == null) return false + val parts = value.split(';').map(String::trim) + if (!parts.first().equals("application/scim+json", ignoreCase = true)) return false + return parts.drop(1).all { it.equals("charset=utf-8", ignoreCase = true) } + } + + private fun parseQuery(raw: String?): Map { + if (raw.isNullOrEmpty()) return emptyMap() + require(raw.length <= MAXIMUM_QUERY_BYTES) + val result = linkedMapOf() + raw.split('&').forEach { field -> + require(field.isNotEmpty()) + val separator = field.indexOf('=') + require(separator > 0) + val name = decodeQueryComponent(field.substring(0, separator)) + val value = decodeQueryComponent(field.substring(separator + 1)) + require(name.isNotBlank() && name.length <= 100 && value.length <= 2_048) + require(name.none(::isUnsafeQueryCharacter) && value.none(::isUnsafeQueryCharacter)) + require(result.put(name, value) == null) { "Duplicate SCIM query parameter" } + } + return result + } + + private fun decodeQueryComponent(value: String): String { + val bytes = mutableListOf() + var index = 0 + while (index < value.length) { + when (val character = value[index]) { + '%' -> { + require(index + 2 < value.length) + val high = value[index + 1].digitToIntOrNull(16) ?: throw IllegalArgumentException() + val low = value[index + 2].digitToIntOrNull(16) ?: throw IllegalArgumentException() + bytes += ((high shl 4) or low).toByte() + index += 3 + } + '+' -> { + bytes += ' '.code.toByte() + index += 1 + } + else -> { + require(character.code !in 0xD800..0xDFFF) + bytes += character.toString().encodeToByteArray().toList() + index += 1 + } + } + } + return bytes.toByteArray().decodeToString(throwOnInvalidSequence = true) + } + + private companion object { + const val SCIM_BASE = "/scim/v2" + const val MAXIMUM_QUERY_BYTES = 8_192 + val SAFE_REQUEST_ID = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,254}") + val ERROR_JSON = Json { encodeDefaults = true; explicitNulls = false } + + fun isUnsafeHeaderCharacter(character: Char): Boolean = character.code < 0x20 || character.code == 0x7F + fun isUnsafeQueryCharacter(character: Char): Boolean = character.code < 0x20 || character.code == 0x7F + } + + private class InvalidTransportRequest : IllegalArgumentException() +} diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimPatch.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimPatch.kt new file mode 100644 index 0000000..904d2d6 --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimPatch.kt @@ -0,0 +1,307 @@ +package codes.yousef.aether.auth.scim + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +internal data class MutableUserDocument( + var externalId: String?, + var userName: String, + var name: ScimName?, + var displayName: String?, + var nickName: String?, + var profileUrl: String?, + var title: String?, + var userType: String?, + var preferredLanguage: String?, + var locale: String?, + var timezone: String?, + var active: Boolean, + val emails: MutableList, + val phoneNumbers: MutableList, + val ims: MutableList, + val photos: MutableList, + val addresses: MutableList, + val entitlements: MutableList, + val roles: MutableList, + val x509Certificates: MutableList +) { + fun immutable(): ScimUserDocument = ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = externalId, + userName = userName, + name = name, + displayName = displayName, + nickName = nickName, + profileUrl = profileUrl, + title = title, + userType = userType, + preferredLanguage = preferredLanguage, + locale = locale, + timezone = timezone, + active = active, + emails = emails.toList(), + phoneNumbers = phoneNumbers.toList(), + ims = ims.toList(), + photos = photos.toList(), + addresses = addresses.toList(), + entitlements = entitlements.toList(), + roles = roles.toList(), + x509Certificates = x509Certificates.toList() + ) +} + +internal data class MutableGroupDocument( + var externalId: String?, + var displayName: String, + val members: LinkedHashMap +) { + fun immutable(): ScimGroupDocument = ScimGroupDocument( + schemas = listOf(ScimSchemas.GROUP), + externalId = externalId, + displayName = displayName, + members = members.values.toList() + ) +} + +internal object ScimPatchApplicator { + private val json = Json { ignoreUnknownKeys = false; explicitNulls = false } + private val memberFilter = Regex( + "^members\\[value\\s+eq\\s+\"((?:[^\"\\\\]|\\\\.)+)\"]$", + RegexOption.IGNORE_CASE + ) + + fun user(record: ScimUserRecord, request: ScimPatchRequest): ScimUserDocument { + val target = MutableUserDocument( + externalId = record.externalId, + userName = record.userName, + name = record.name, + displayName = record.displayName, + nickName = record.nickName, + profileUrl = record.profileUrl, + title = record.title, + userType = record.userType, + preferredLanguage = record.preferredLanguage, + locale = record.locale, + timezone = record.timezone, + active = record.active, + emails = record.emails.toMutableList(), + phoneNumbers = record.phoneNumbers.toMutableList(), + ims = record.ims.toMutableList(), + photos = record.photos.toMutableList(), + addresses = record.addresses.toMutableList(), + entitlements = record.entitlements.toMutableList(), + roles = record.roles.toMutableList(), + x509Certificates = record.x509Certificates.toMutableList() + ) + request.operations.forEach { operation -> applyUser(target, operation) } + return try { + target.immutable() + } catch (_: IllegalArgumentException) { + throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + } + + fun group(record: ScimGroupRecord, request: ScimPatchRequest): ScimGroupDocument { + val target = MutableGroupDocument( + externalId = record.externalId, + displayName = record.displayName, + members = record.memberUserResourceIds.associateWithTo(linkedMapOf()) { ScimMember(it) } + ) + request.operations.forEach { operation -> applyGroup(target, operation) } + return try { + target.immutable() + } catch (_: IllegalArgumentException) { + throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + } + + private fun applyUser(target: MutableUserDocument, operation: ScimPatchOperation) { + val path = operation.path?.lowercase() + if (path == null) { + if (operation.op == ScimPatchAction.REMOVE) throw ScimPatchException(ScimErrorType.NO_TARGET) + val fields = operation.value as? JsonObject ?: throw ScimPatchException(ScimErrorType.INVALID_VALUE) + fields.forEach { (name, value) -> + applyUser(target, operation.copy(path = name, value = value)) + } + return + } + when (path) { + "username" -> when (operation.op) { + ScimPatchAction.REMOVE -> throw ScimPatchException(ScimErrorType.MUTABILITY) + else -> target.userName = requiredString(operation.value) + } + "externalid" -> target.externalId = nullableString(operation) + "displayname" -> target.displayName = nullableString(operation) + "nickname" -> target.nickName = nullableString(operation) + "profileurl" -> target.profileUrl = nullableString(operation) + "title" -> target.title = nullableString(operation) + "usertype" -> target.userType = nullableString(operation) + "preferredlanguage" -> target.preferredLanguage = nullableString(operation) + "locale" -> target.locale = nullableString(operation) + "timezone" -> target.timezone = nullableString(operation) + "active" -> when (operation.op) { + ScimPatchAction.REMOVE -> throw ScimPatchException(ScimErrorType.MUTABILITY) + else -> target.active = operation.value?.jsonPrimitive?.booleanOrNull + ?: throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + "name" -> target.name = when (operation.op) { + ScimPatchAction.REMOVE -> null + else -> decode(operation.value, ScimName.serializer()) + } + "name.formatted", "name.familyname", "name.givenname", "name.middlename", + "name.honorificprefix", "name.honorificsuffix" -> { + val current = target.name ?: ScimName() + val value = nullableString(operation) + target.name = when (path) { + "name.formatted" -> current.copy(formatted = value) + "name.familyname" -> current.copy(familyName = value) + "name.givenname" -> current.copy(givenName = value) + "name.middlename" -> current.copy(middleName = value) + "name.honorificprefix" -> current.copy(honorificPrefix = value) + else -> current.copy(honorificSuffix = value) + } + } + "emails" -> { + when (operation.op) { + ScimPatchAction.REMOVE -> target.emails.clear() + ScimPatchAction.REPLACE -> { + target.emails.clear() + target.emails += emails(operation.value) + } + ScimPatchAction.ADD -> { + emails(operation.value).forEach { added -> + val existing = target.emails.indexOfFirst { it.value.equals(added.value, ignoreCase = true) } + if (existing >= 0) target.emails[existing] = added else target.emails += added + } + } + } + } + "phonenumbers" -> applyMultiValues(target.phoneNumbers, operation) + "ims" -> applyMultiValues(target.ims, operation) + "photos" -> applyMultiValues(target.photos, operation) + "entitlements" -> applyMultiValues(target.entitlements, operation) + "roles" -> applyMultiValues(target.roles, operation) + "x509certificates" -> applyMultiValues(target.x509Certificates, operation) + "addresses" -> when (operation.op) { + ScimPatchAction.REMOVE -> target.addresses.clear() + ScimPatchAction.REPLACE -> { + target.addresses.clear() + target.addresses += addresses(operation.value) + } + ScimPatchAction.ADD -> target.addresses += addresses(operation.value) + } + "password" -> throw ScimPatchException(ScimErrorType.SENSITIVE) + "schemas", "id", "meta", "groups" -> throw ScimPatchException(ScimErrorType.MUTABILITY) + else -> throw ScimPatchException(ScimErrorType.INVALID_PATH) + } + } + + private fun applyGroup(target: MutableGroupDocument, operation: ScimPatchOperation) { + val originalPath = operation.path + val path = originalPath?.lowercase() + if (path == null) { + if (operation.op == ScimPatchAction.REMOVE) throw ScimPatchException(ScimErrorType.NO_TARGET) + val fields = operation.value as? JsonObject ?: throw ScimPatchException(ScimErrorType.INVALID_VALUE) + fields.forEach { (name, value) -> applyGroup(target, operation.copy(path = name, value = value)) } + return + } + when (path) { + "displayname" -> when (operation.op) { + ScimPatchAction.REMOVE -> throw ScimPatchException(ScimErrorType.MUTABILITY) + else -> target.displayName = requiredString(operation.value) + } + "externalid" -> target.externalId = nullableString(operation) + "members" -> when (operation.op) { + ScimPatchAction.REMOVE -> target.members.clear() + ScimPatchAction.REPLACE -> { + target.members.clear() + members(operation.value).forEach { target.members[it.value] = it } + } + ScimPatchAction.ADD -> members(operation.value).forEach { target.members[it.value] = it } + } + "schemas", "id", "meta" -> throw ScimPatchException(ScimErrorType.MUTABILITY) + else -> { + val match = memberFilter.matchEntire(originalPath) + ?: throw ScimPatchException(ScimErrorType.INVALID_PATH) + if (operation.op != ScimPatchAction.REMOVE) throw ScimPatchException(ScimErrorType.INVALID_PATH) + val memberId = decodeJsonString(match.groupValues[1]) + // RFC 7644 section 3.5.2.2: removing a member that is not present is a successful no-op. + target.members.remove(memberId) + } + } + } + + private fun nullableString(operation: ScimPatchOperation): String? = when (operation.op) { + ScimPatchAction.REMOVE -> null + else -> requiredString(operation.value) + } + + private fun requiredString(element: JsonElement?): String { + val primitive = element as? JsonPrimitive ?: throw ScimPatchException(ScimErrorType.INVALID_VALUE) + if (!primitive.isString) throw ScimPatchException(ScimErrorType.INVALID_VALUE) + return primitive.contentOrNull?.takeIf { it.isNotBlank() } + ?: throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + + private fun emails(value: JsonElement?): List = when (value) { + is JsonArray -> decode(value, ListSerializer(ScimEmail.serializer())) + is JsonObject -> listOf(decode(value, ScimEmail.serializer())) + else -> throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + + private fun multiValues(value: JsonElement?): List = when (value) { + is JsonArray -> decode(value, ListSerializer(ScimMultiValue.serializer())) + is JsonObject -> listOf(decode(value, ScimMultiValue.serializer())) + else -> throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + + private fun addresses(value: JsonElement?): List = when (value) { + is JsonArray -> decode(value, ListSerializer(ScimAddress.serializer())) + is JsonObject -> listOf(decode(value, ScimAddress.serializer())) + else -> throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + + private fun applyMultiValues(target: MutableList, operation: ScimPatchOperation) { + when (operation.op) { + ScimPatchAction.REMOVE -> target.clear() + ScimPatchAction.REPLACE -> { + target.clear() + target += multiValues(operation.value) + } + ScimPatchAction.ADD -> multiValues(operation.value).forEach { added -> + val existing = target.indexOfFirst { it.value == added.value && it.type == added.type } + if (existing >= 0) target[existing] = added else target += added + } + } + } + + private fun members(value: JsonElement?): List = when (value) { + is JsonArray -> decode(value, ListSerializer(ScimMember.serializer())) + is JsonObject -> listOf(decode(value, ScimMember.serializer())) + else -> throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } + + private fun decodeJsonString(escapedContent: String): String = try { + json.parseToJsonElement("\"$escapedContent\"").jsonPrimitive.content + } catch (_: Throwable) { + throw ScimPatchException(ScimErrorType.INVALID_PATH) + } + + private fun decode(value: JsonElement?, deserializer: DeserializationStrategy): T = try { + json.decodeFromJsonElement(deserializer, value ?: throw ScimPatchException(ScimErrorType.INVALID_VALUE)) + } catch (failure: ScimPatchException) { + throw failure + } catch (_: Throwable) { + throw ScimPatchException(ScimErrorType.INVALID_VALUE) + } +} + +internal class ScimPatchException(val type: ScimErrorType) : IllegalArgumentException("Invalid SCIM PATCH operation") diff --git a/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimProtocol.kt b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimProtocol.kt new file mode 100644 index 0000000..3e83e21 --- /dev/null +++ b/aether-auth-scim/src/commonMain/kotlin/codes/yousef/aether/auth/scim/ScimProtocol.kt @@ -0,0 +1,347 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.ScimOperationId +import kotlin.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +object ScimSchemas { + const val USER = "urn:ietf:params:scim:schemas:core:2.0:User" + const val GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group" + const val PATCH_OPERATION = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + const val LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + const val ERROR = "urn:ietf:params:scim:api:messages:2.0:Error" +} + +@Serializable +data class ScimName( + val formatted: String? = null, + val familyName: String? = null, + val givenName: String? = null, + val middleName: String? = null, + val honorificPrefix: String? = null, + val honorificSuffix: String? = null +) { + init { + listOf(formatted, familyName, givenName, middleName, honorificPrefix, honorificSuffix).forEach { + require(it == null || it.length <= 200) { "SCIM name component is too long" } + } + } +} + +@Serializable +data class ScimEmail( + val value: String, + val type: String? = null, + val primary: Boolean = false, + val display: String? = null +) { + init { + require(value.length in 3..320 && '@' in value && value.none(Char::isWhitespace)) { + "Invalid SCIM email" + } + require(type == null || (type.isNotBlank() && type.length <= 64)) { "Invalid SCIM email type" } + require(display == null || display.length <= 200) { "SCIM email display is too long" } + } +} + +@Serializable +data class ScimMultiValue( + val value: String, + val type: String? = null, + val primary: Boolean = false, + val display: String? = null, + @SerialName("\$ref") val reference: String? = null +) { + init { + require(value.isNotBlank() && value.length <= 16_384) { "Invalid SCIM multi-valued attribute" } + require(type == null || (type.isNotBlank() && type.length <= 64)) { "Invalid SCIM attribute type" } + require(display == null || display.length <= 200) { "SCIM display value is too long" } + require(reference == null || reference.length <= 2_048) { "SCIM reference is too long" } + } +} + +@Serializable +data class ScimAddress( + val formatted: String? = null, + val streetAddress: String? = null, + val locality: String? = null, + val region: String? = null, + val postalCode: String? = null, + val country: String? = null, + val type: String? = null, + val primary: Boolean = false +) { + init { + listOf(formatted, streetAddress, locality, region, postalCode).forEach { + require(it == null || it.length <= 1_000) { "SCIM address component is too long" } + } + require(country == null || country.length == 2) { "SCIM address country must be a two-letter code" } + require(type == null || (type.isNotBlank() && type.length <= 64)) { "Invalid SCIM address type" } + } +} + +@Serializable +data class ScimMember( + val value: String, + val display: String? = null, + @SerialName("\$ref") val reference: String? = null, + val type: String? = null +) { + init { + require(Regex("[A-Za-z0-9_-][A-Za-z0-9._:-]{0,254}").matches(value)) { "Invalid SCIM member value" } + require(display == null || display.length <= 200) { "SCIM member display is too long" } + require(reference == null || reference.length <= 2_048) { "SCIM member reference is too long" } + require(type == null || type in setOf("User", "Group", "direct", "indirect")) { + "Invalid SCIM member type" + } + } +} + +/** Strict writable representation accepted for User POST and PUT. */ +@Serializable +data class ScimUserDocument( + val schemas: List, + /** Read-only when supplied by a client and ignored by the engine. */ + val id: String? = null, + val externalId: String? = null, + val userName: String, + val name: ScimName? = null, + val displayName: String? = null, + val nickName: String? = null, + val profileUrl: String? = null, + val title: String? = null, + val userType: String? = null, + val preferredLanguage: String? = null, + val locale: String? = null, + val timezone: String? = null, + val active: Boolean = true, + /** Password provisioning is deliberately unsupported by Aether's passkey-only identity model. */ + val password: String? = null, + val emails: List = emptyList(), + val phoneNumbers: List = emptyList(), + val ims: List = emptyList(), + val photos: List = emptyList(), + val addresses: List = emptyList(), + val entitlements: List = emptyList(), + /** Informational only; tenant authorization is derived exclusively from mapped Groups. */ + val roles: List = emptyList(), + val x509Certificates: List = emptyList(), + /** Read-only when supplied by a client and ignored by the engine. */ + val groups: List = emptyList(), + /** Read-only when supplied by a client and ignored by the engine. */ + val meta: ScimMeta? = null +) { + init { + require(schemas == listOf(ScimSchemas.USER)) { "Unsupported SCIM User schema" } + require(userName.isNotBlank() && userName.length <= 320) { "userName must be 1..320 characters" } + require(externalId == null || (externalId.isNotBlank() && externalId.length <= 1_024)) { + "externalId must be absent or 1..1024 characters" + } + require(displayName == null || (displayName.isNotBlank() && displayName.length <= 200)) { + "displayName must be absent or 1..200 characters" + } + listOf(nickName, title, userType, preferredLanguage, locale, timezone).forEach { + require(it == null || (it.isNotBlank() && it.length <= 200)) { "Invalid SCIM User attribute" } + } + require(profileUrl == null || (profileUrl.isNotBlank() && profileUrl.length <= 2_048)) { + "Invalid SCIM profileUrl" + } + require(password == null) { "Password provisioning is not supported" } + require(emails.size <= 20) { "Too many SCIM emails" } + require(emails.count { it.primary } <= 1) { "At most one SCIM email may be primary" } + listOf(phoneNumbers, ims, photos, entitlements, roles, x509Certificates).forEach { + require(it.size <= 100) { "Too many SCIM attribute values" } + require(it.count { value -> value.primary } <= 1) { "At most one SCIM attribute value may be primary" } + } + require(addresses.size <= 20 && addresses.count { it.primary } <= 1) { "Too many SCIM addresses" } + } +} + +/** Strict writable representation accepted for Group POST and PUT. */ +@Serializable +data class ScimGroupDocument( + val schemas: List, + /** Read-only when supplied by a client and ignored by the engine. */ + val id: String? = null, + val externalId: String? = null, + val displayName: String, + val members: List = emptyList(), + /** Read-only when supplied by a client and ignored by the engine. */ + val meta: ScimMeta? = null +) { + init { + require(schemas == listOf(ScimSchemas.GROUP)) { "Unsupported SCIM Group schema" } + require(displayName.isNotBlank() && displayName.length <= 200) { "displayName must be 1..200 characters" } + require(externalId == null || (externalId.isNotBlank() && externalId.length <= 1_024)) { + "externalId must be absent or 1..1024 characters" + } + require(members.size <= 5_000) { "Too many SCIM group members" } + require(members.map { it.value }.toSet().size == members.size) { "Duplicate SCIM group member" } + } +} + +@Serializable +data class ScimMeta( + val resourceType: String, + val created: Instant, + val lastModified: Instant, + val location: String, + val version: String +) { + init { + require(resourceType == "User" || resourceType == "Group") + require(lastModified >= created) + require(location.length <= 2_048 && (location.startsWith("https://") || location.startsWith("http://"))) + require(version.isNotBlank() && version.length <= 200) + } +} + +@Serializable +data class ScimUserResource( + val schemas: List = listOf(ScimSchemas.USER), + val id: String, + val externalId: String? = null, + val userName: String, + val name: ScimName? = null, + val displayName: String? = null, + val nickName: String? = null, + val profileUrl: String? = null, + val title: String? = null, + val userType: String? = null, + val preferredLanguage: String? = null, + val locale: String? = null, + val timezone: String? = null, + val active: Boolean, + val emails: List = emptyList(), + val phoneNumbers: List = emptyList(), + val ims: List = emptyList(), + val photos: List = emptyList(), + val addresses: List = emptyList(), + val entitlements: List = emptyList(), + val roles: List = emptyList(), + val x509Certificates: List = emptyList(), + val groups: List = emptyList(), + val meta: ScimMeta +) + +@Serializable +data class ScimGroupResource( + val schemas: List = listOf(ScimSchemas.GROUP), + val id: String, + val externalId: String? = null, + val displayName: String, + val members: List = emptyList(), + val meta: ScimMeta +) + +@Serializable +data class ScimUserListResponse( + val schemas: List = listOf(ScimSchemas.LIST_RESPONSE), + val totalResults: Int, + val startIndex: Int, + val itemsPerPage: Int, + @SerialName("Resources") val resources: List +) + +@Serializable +data class ScimGroupListResponse( + val schemas: List = listOf(ScimSchemas.LIST_RESPONSE), + val totalResults: Int, + val startIndex: Int, + val itemsPerPage: Int, + @SerialName("Resources") val resources: List +) + +@Serializable +enum class ScimPatchAction { + @SerialName("add") ADD, + @SerialName("remove") REMOVE, + @SerialName("replace") REPLACE +} + +@Serializable +data class ScimPatchOperation( + val op: ScimPatchAction, + val path: String? = null, + val value: JsonElement? = null +) + +@Serializable +data class ScimPatchRequest( + val schemas: List, + @SerialName("Operations") val operations: List +) { + init { + require(schemas == listOf(ScimSchemas.PATCH_OPERATION)) { "Unsupported SCIM PATCH schema" } + require(operations.isNotEmpty() && operations.size <= 100) { "PATCH must contain 1..100 operations" } + } +} + +@Serializable +enum class ScimErrorType(val wireName: String) { + @SerialName("invalidFilter") INVALID_FILTER("invalidFilter"), + @SerialName("tooMany") TOO_MANY("tooMany"), + @SerialName("uniqueness") UNIQUENESS("uniqueness"), + @SerialName("mutability") MUTABILITY("mutability"), + @SerialName("invalidSyntax") INVALID_SYNTAX("invalidSyntax"), + @SerialName("invalidPath") INVALID_PATH("invalidPath"), + @SerialName("noTarget") NO_TARGET("noTarget"), + @SerialName("invalidValue") INVALID_VALUE("invalidValue"), + @SerialName("sensitive") SENSITIVE("sensitive"), +} + +@Serializable +data class ScimErrorResponse( + val schemas: List = listOf(ScimSchemas.ERROR), + val status: String, + val scimType: String? = null, + val detail: String +) + +enum class ScimHttpMethod { GET, POST, PUT, PATCH, DELETE } + +class ScimRequest( + val method: ScimHttpMethod, + val path: String, + query: Map = emptyMap(), + headers: Map = emptyMap(), + body: ByteArray = ByteArray(0), + val operationId: ScimOperationId? = null, + val requestId: String? = null +) { + val query: Map = query.toMap() + private val normalizedHeaders = headers.mapKeys { it.key.lowercase() } + private val bodyValue = body.copyOf() + + init { + require(path.startsWith('/') && '?' !in path && '#' !in path && path.length <= 2_048) { + "SCIM path must be an absolute path without query or fragment" + } + require(query.keys.all { it.isNotBlank() && it.length <= 100 }) { "Invalid SCIM query parameter" } + require(query.values.all { it.length <= 2_048 }) { "SCIM query parameter is too long" } + require(normalizedHeaders.all { (name, value) -> name.length <= 100 && value.length <= 8_192 }) { + "SCIM header is too long" + } + require(requestId == null || (requestId.isNotBlank() && requestId.length <= 255)) { "Invalid request ID" } + } + + fun header(name: String): String? = normalizedHeaders[name.lowercase()] + fun bodyBytes(): ByteArray = bodyValue.copyOf() +} + +class ScimResponse( + val status: Int, + headers: Map = emptyMap(), + body: ByteArray = ByteArray(0) +) { + val headers: Map = headers.toMap() + private val bodyValue = body.copyOf() + + init { require(status in 100..599) } + + fun bodyBytes(): ByteArray = bodyValue.copyOf() +} + +internal fun weakEtag(version: Long): String = "W/\"$version\"" diff --git a/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/BoundedScimJsonTest.kt b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/BoundedScimJsonTest.kt new file mode 100644 index 0000000..42a7e32 --- /dev/null +++ b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/BoundedScimJsonTest.kt @@ -0,0 +1,32 @@ +package codes.yousef.aether.auth.scim + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class BoundedScimJsonTest { + @Test + fun rejectsDuplicateKeysIncludingEscapedAliases() { + val parser = BoundedScimJson() + assertFailsWith { + parser.parse("{\"userName\":\"a\",\"user\\u004eame\":\"b\"}".encodeToByteArray()) + } + } + + @Test + fun rejectsExcessiveDepthAndInputBeforeTypedDecoding() { + val parser = BoundedScimJson(ScimJsonLimits(maximumBytes = 64, maximumDepth = 3)) + assertFailsWith { parser.parse("[[[[]]]]".encodeToByteArray()) } + assertFailsWith { parser.parse(ByteArray(65) { 'x'.code.toByte() }) } + } + + @Test + fun decodesUnicodeAndNumbersWithoutTurningNumbersIntoStrings() { + val parsed = BoundedScimJson().parse("{\"name\":\"A\\u006cice\",\"n\":2}".encodeToByteArray()).jsonObject + assertEquals("Alice", parsed.getValue("name").jsonPrimitive.content) + assertEquals(2, parsed.getValue("n").jsonPrimitive.content.toInt()) + assertEquals(false, parsed.getValue("n").jsonPrimitive.isString) + } +} diff --git a/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/InMemoryScimDirectory.kt b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/InMemoryScimDirectory.kt new file mode 100644 index 0000000..e3f9d42 --- /dev/null +++ b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/InMemoryScimDirectory.kt @@ -0,0 +1,161 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.ScimOperationId + +internal class InMemoryScimDirectory : ScimDirectory { + private val users = linkedMapOf() + private val groups = linkedMapOf() + private val operations = linkedMapOf() + private val completed = linkedMapOf() + private val resourceReservations = linkedMapOf, ScimOperationId>() + + override suspend fun findUser( + organizationId: OrganizationId, + id: String + ): ScimDirectoryResult = success(users[id]?.takeIf { + it.organizationId == organizationId && !it.deleted + }) + + override suspend fun listUsers( + organizationId: OrganizationId + ): ScimDirectoryResult> = success( + users.values.filter { it.organizationId == organizationId && !it.deleted } + ) + + override suspend fun findGroup( + organizationId: OrganizationId, + id: String + ): ScimDirectoryResult = success(groups[id]?.takeIf { + it.organizationId == organizationId && !it.deleted + }) + + override suspend fun listGroups( + organizationId: OrganizationId + ): ScimDirectoryResult> = success( + groups.values.filter { it.organizationId == organizationId && !it.deleted } + ) + + override suspend fun findOperation( + operationId: ScimOperationId + ): ScimDirectoryResult = success(operations[operationId]) + + override suspend fun reserveOperation( + reservation: ScimOperationReservation + ): ScimDirectoryResult { + val existing = operations[reservation.operationId] + if (existing != null) { + return if (existing.fingerprint == reservation.fingerprint) success(existing) else failure( + ScimDirectoryErrorCode.IDEMPOTENCY_CONFLICT + ) + } + val resourceKey = reservation.kind to reservation.resourceId + val holder = resourceReservations[resourceKey] + if (holder != null && holder != reservation.operationId && completed[holder] == null) { + return failure(ScimDirectoryErrorCode.VERSION_CONFLICT) + } + when (reservation.kind) { + ScimResourceKind.USER -> { + val desired = requireNotNull(reservation.desiredUser) + if (!versionMatches(users[desired.id]?.version, reservation.expectedProjectionVersion)) { + return failure(ScimDirectoryErrorCode.VERSION_CONFLICT) + } + if (!desired.deleted && users.values.any { + !it.deleted && it.organizationId == desired.organizationId && it.id != desired.id && + (it.userName.equals(desired.userName, ignoreCase = true) || + (desired.externalId != null && it.externalId == desired.externalId)) + }) { + return failure(ScimDirectoryErrorCode.UNIQUENESS_CONFLICT) + } + if (!desired.deleted && operations.values.any { pending -> + completed[pending.operationId] == null && pending.operationId != reservation.operationId && + pending.desiredUser?.let { + !it.deleted && it.organizationId == desired.organizationId && + (it.userName.equals(desired.userName, ignoreCase = true) || + (desired.externalId != null && it.externalId == desired.externalId)) + } == true + }) { + return failure(ScimDirectoryErrorCode.UNIQUENESS_CONFLICT) + } + } + ScimResourceKind.GROUP -> { + val desired = requireNotNull(reservation.desiredGroup) + if (!versionMatches(groups[desired.id]?.version, reservation.expectedProjectionVersion)) { + return failure(ScimDirectoryErrorCode.VERSION_CONFLICT) + } + if (!desired.deleted && groups.values.any { + !it.deleted && it.organizationId == desired.organizationId && it.id != desired.id && + (it.displayName.equals(desired.displayName, ignoreCase = true) || + (desired.externalId != null && it.externalId == desired.externalId)) + }) { + return failure(ScimDirectoryErrorCode.UNIQUENESS_CONFLICT) + } + if (!desired.deleted && operations.values.any { pending -> + completed[pending.operationId] == null && pending.operationId != reservation.operationId && + pending.desiredGroup?.let { + !it.deleted && it.organizationId == desired.organizationId && + (it.displayName.equals(desired.displayName, ignoreCase = true) || + (desired.externalId != null && it.externalId == desired.externalId)) + } == true + }) { + return failure(ScimDirectoryErrorCode.UNIQUENESS_CONFLICT) + } + } + } + operations[reservation.operationId] = reservation + resourceReservations[resourceKey] = reservation.operationId + return success(reservation) + } + + override suspend fun completeOperation( + operationId: ScimOperationId + ): ScimDirectoryResult { + completed[operationId]?.let { return success(it.copy(alreadyCompleted = true)) } + val reservation = operations[operationId] ?: return failure(ScimDirectoryErrorCode.NOT_FOUND) + val commit = when (reservation.kind) { + ScimResourceKind.USER -> { + val desired = requireNotNull(reservation.desiredUser) + val current = users[desired.id] + if (!versionMatches(current?.version, reservation.expectedProjectionVersion)) { + return failure(ScimDirectoryErrorCode.VERSION_CONFLICT) + } + if (!desired.deleted && users.values.any { + !it.deleted && it.organizationId == desired.organizationId && it.id != desired.id && + (it.userName.equals(desired.userName, ignoreCase = true) || + (desired.externalId != null && it.externalId == desired.externalId)) + }) { + return failure(ScimDirectoryErrorCode.UNIQUENESS_CONFLICT) + } + users[desired.id] = desired + ScimDirectoryCommit(user = desired) + } + ScimResourceKind.GROUP -> { + val desired = requireNotNull(reservation.desiredGroup) + val current = groups[desired.id] + if (!versionMatches(current?.version, reservation.expectedProjectionVersion)) { + return failure(ScimDirectoryErrorCode.VERSION_CONFLICT) + } + if (!desired.deleted && groups.values.any { + !it.deleted && it.organizationId == desired.organizationId && it.id != desired.id && + (it.displayName.equals(desired.displayName, ignoreCase = true) || + (desired.externalId != null && it.externalId == desired.externalId)) + }) { + return failure(ScimDirectoryErrorCode.UNIQUENESS_CONFLICT) + } + groups[desired.id] = desired + ScimDirectoryCommit(group = desired) + } + } + completed[operationId] = commit + return success(commit) + } + + private fun versionMatches(current: Long?, expected: Long?): Boolean = when (expected) { + null -> current == null + else -> current == expected + } + + private fun success(value: T): ScimDirectoryResult = ScimDirectoryResult.Success(value) + private fun failure(code: ScimDirectoryErrorCode): ScimDirectoryResult.Failure = + ScimDirectoryResult.Failure(ScimDirectoryError(code)) +} diff --git a/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimEngineTest.kt b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimEngineTest.kt new file mode 100644 index 0000000..31bcf78 --- /dev/null +++ b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimEngineTest.kt @@ -0,0 +1,350 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.ScimOperationId +import codes.yousef.aether.auth.UserState +import codes.yousef.aether.auth.testkit.DeterministicIdentityRuntime +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.auth.testkit.InMemoryIdentityStore +import codes.yousef.aether.auth.testkit.InMemoryIdentityStoreSeed +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class ScimEngineTest { + private val json = Json { ignoreUnknownKeys = false; explicitNulls = false; encodeDefaults = false } + + @Test + fun userCrudIsIdempotentAndDeprovisionsOnlyTenantMembership() = runTest { + val fixture = Fixture() + val createBody = json.encodeToString( + ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = "directory-user-1", + userName = "alice@example.test", + displayName = "Alice", + emails = listOf(ScimEmail("alice@example.test", primary = true)) + ) + ).encodeToByteArray() + val create = fixture.engine.handle( + request(ScimHttpMethod.POST, "/scim/v2/Users", "create-user-1", createBody) + ) + assertEquals(201, create.status) + assertTrue(create.bodyBytes().decodeToString().contains("\"schemas\":[\"${ScimSchemas.USER}\"]")) + val resource = json.decodeFromString(ScimUserResource.serializer(), create.bodyBytes().decodeToString()) + + val retried = fixture.engine.handle( + request(ScimHttpMethod.POST, "/scim/v2/Users", "create-user-1", createBody) + ) + assertEquals(201, retried.status) + val retriedResource = json.decodeFromString(ScimUserResource.serializer(), retried.bodyBytes().decodeToString()) + assertEquals(resource.id, retriedResource.id) + assertEquals(2, fixture.identity.snapshot().appliedScimOperationIds.size) + assertEquals(1, fixture.identity.snapshot().appliedScimBatchOperationIds.size) + + val patchBody = """{ + "schemas":["${ScimSchemas.PATCH_OPERATION}"], + "Operations":[{"op":"replace","path":"active","value":false}] + }""".trimIndent().encodeToByteArray() + val patched = fixture.engine.handle( + request( + ScimHttpMethod.PATCH, + "/scim/v2/Users/${resource.id}", + "deactivate-user-1", + patchBody, + mapOf("If-Match" to "W/\"1\"") + ) + ) + assertEquals(200, patched.status) + val snapshot = fixture.identity.snapshot() + assertEquals(UserState.ACTIVE, snapshot.users.single().state) + assertEquals(MembershipState.REMOVED, snapshot.memberships.single().state) + assertEquals(2, snapshot.appliedScimBatchOperationIds.size) + } + + @Test + fun listSupportsEqualityFilteringAndOneBasedPagination() = runTest { + val fixture = Fixture() + fixture.createUser("alice@example.test", "ext-alice", "op-alice") + fixture.createUser("bob@example.test", "ext-bob", "op-bob") + val response = fixture.engine.handle( + ScimRequest( + method = ScimHttpMethod.GET, + path = "/scim/v2/Users", + query = mapOf("filter" to "userName eq \"BOB@example.test\"", "startIndex" to "1", "count" to "1") + ) + ) + assertEquals(200, response.status) + val list = json.decodeFromString(ScimUserListResponse.serializer(), response.bodyBytes().decodeToString()) + assertEquals(1, list.totalResults) + assertEquals("bob@example.test", list.resources.single().userName) + } + + @Test + fun staleEtagFailsWithoutApplyingAMutation() = runTest { + val fixture = Fixture() + val user = fixture.createUser("alice@example.test", "ext-alice", "op-alice") + val body = json.encodeToString( + ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = "ext-alice", + userName = "alice@example.test", + displayName = "Changed" + ) + ).encodeToByteArray() + val response = fixture.engine.handle( + request( + ScimHttpMethod.PUT, + "/scim/v2/Users/${user.id}", + "replace-alice", + body, + mapOf("If-Match" to "W/\"99\"") + ) + ) + assertEquals(412, response.status) + assertEquals(2, fixture.identity.snapshot().appliedScimOperationIds.size) + } + + @Test + fun coreUserAttributesRoundTripButInformationalRolesGrantNothing() = runTest { + val fixture = Fixture() + val body = json.encodeToString( + ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = "full-user", + userName = "full@example.test", + name = ScimName(givenName = "Full", familyName = "User"), + nickName = "fu", + profileUrl = "https://example.test/profiles/full", + title = "Engineer", + userType = "Employee", + preferredLanguage = "en", + locale = "en-US", + timezone = "Asia/Riyadh", + emails = listOf(ScimEmail("full@example.test", type = "work", primary = true)), + phoneNumbers = listOf(ScimMultiValue("+966500000000", type = "work")), + ims = listOf(ScimMultiValue("full-user", type = "matrix")), + photos = listOf(ScimMultiValue("https://example.test/full.png", primary = true)), + addresses = listOf(ScimAddress(locality = "Riyadh", country = "SA", type = "work")), + entitlements = listOf(ScimMultiValue("billing-admin")), + roles = listOf(ScimMultiValue("owner")), + x509Certificates = listOf(ScimMultiValue("MIIBfixture")) + ) + ).encodeToByteArray() + val response = fixture.engine.handle(request(ScimHttpMethod.POST, "/scim/v2/Users", "full-user-op", body)) + assertEquals(201, response.status) + val user = json.decodeFromString(ScimUserResource.serializer(), response.bodyBytes().decodeToString()) + assertEquals("Engineer", user.title) + assertEquals("Riyadh", user.addresses.single().locality) + assertEquals("owner", user.roles.single().value) + assertEquals(OrganizationRole.VIEWER, fixture.identity.snapshot().memberships.single().role) + val identityUser = fixture.identity.snapshot().users.single() + assertEquals("en-US", identityUser.locale) + assertEquals("Asia/Riyadh", identityUser.timeZone) + assertEquals("https://example.test/full.png", identityUser.avatarUrl) + } + + @Test + fun passwordProvisioningIsRejectedAndNeverPersistsIdentityState() = runTest { + val fixture = Fixture() + val body = """{ + "schemas":["${ScimSchemas.USER}"], + "userName":"password@example.test", + "password":"must-not-be-stored" + }""".trimIndent().encodeToByteArray() + val response = fixture.engine.handle(request(ScimHttpMethod.POST, "/scim/v2/Users", "password-op", body)) + assertEquals(400, response.status) + assertTrue(fixture.identity.snapshot().users.isEmpty()) + assertTrue(response.bodyBytes().decodeToString().contains("must-not-be-stored").not()) + } + + @Test + fun uniquenessIsReservedBeforeAnySecondIdentityMutation() = runTest { + val fixture = Fixture() + fixture.createUser("duplicate@example.test", "external-one", "first-create") + val duplicateBody = json.encodeToString( + ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = "external-two", + userName = "DUPLICATE@example.test" + ) + ).encodeToByteArray() + val duplicate = fixture.engine.handle( + request(ScimHttpMethod.POST, "/scim/v2/Users", "second-create", duplicateBody) + ) + assertEquals(409, duplicate.status) + assertEquals(1, fixture.identity.snapshot().users.size) + assertEquals(2, fixture.identity.snapshot().appliedScimOperationIds.size) + } + + @Test + fun reusingOperationIdWithDifferentBodyFailsClosed() = runTest { + val fixture = Fixture() + fixture.createUser("alice@example.test", "ext-alice", "same-operation") + val changed = json.encodeToString( + ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = "ext-bob", + userName = "bob@example.test" + ) + ).encodeToByteArray() + val response = fixture.engine.handle( + request(ScimHttpMethod.POST, "/scim/v2/Users", "same-operation", changed) + ) + assertEquals(409, response.status) + assertEquals(1, fixture.identity.snapshot().users.size) + } + + @Test + fun mappedGroupChangesRoleAndUnknownGroupGrantsNothing() = runTest { + val fixture = Fixture(groupMappings = mapOf("directory-admins" to OrganizationRole.ADMIN)) + val alice = fixture.createUser("alice@example.test", "ext-alice", "op-alice") + val bob = fixture.createUser("bob@example.test", "ext-bob", "op-bob") + + val mapped = fixture.createGroup("Admins", "directory-admins", alice.id, "group-admins") + val afterMapped = fixture.identity.snapshot().memberships.single { it.userId.value == alice.id } + assertEquals(OrganizationRole.ADMIN, afterMapped.role) + + fixture.createGroup("Mystery", "unknown-group", bob.id, "group-mystery") + val afterUnknown = fixture.identity.snapshot().memberships.single { it.userId.value == bob.id } + assertEquals(OrganizationRole.VIEWER, afterUnknown.role) + + val patch = """{ + "schemas":["${ScimSchemas.PATCH_OPERATION}"], + "Operations":[{"op":"remove","path":"members[value eq \"${alice.id}\"]"}] + }""".trimIndent().encodeToByteArray() + val removed = fixture.engine.handle( + request( + ScimHttpMethod.PATCH, + "/scim/v2/Groups/${mapped.id}", + "group-admins-remove", + patch, + mapOf("If-Match" to "W/\"1\"") + ) + ) + assertEquals(200, removed.status) + val afterRemoval = fixture.identity.snapshot().memberships.single { it.userId.value == alice.id } + assertEquals(OrganizationRole.VIEWER, afterRemoval.role) + assertEquals(2L, fixture.identity.snapshot().scimGroups.single { it.id == mapped.id }.version) + } + + @Test + fun ownerGroupMappingIsRejectedByConfiguration() { + assertFailsWith { + ScimConfig( + organizationId = IdentityFixtures.organizationId("owner-mapping-config"), + providerName = "test-directory", + scimBaseUrl = "https://identity.example.test/scim/v2", + groupRoleMappings = mapOf("directory-owners" to OrganizationRole.OWNER) + ) + } + } + + @Test + fun engineNeverPromotesScimMembershipToOwnerFromMutatedMapping() = runTest { + val mappings = mutableMapOf("directory-admins" to OrganizationRole.ADMIN) + val fixture = Fixture(groupMappings = mappings) + mappings["directory-owners"] = OrganizationRole.OWNER + val alice = fixture.createUser("alice@example.test", "ext-alice", "owner-map-user") + + fixture.createGroup("Owners", "directory-owners", alice.id, "owner-map-group") + + val membership = fixture.identity.snapshot().memberships.single { it.userId.value == alice.id } + assertEquals(OrganizationRole.VIEWER, membership.role) + } + + @Test + fun deleteReturnsTombstoneBehaviorAndNeverDeletesStableUser() = runTest { + val fixture = Fixture() + val user = fixture.createUser("alice@example.test", "ext-alice", "op-alice") + val deleted = fixture.engine.handle( + request( + ScimHttpMethod.DELETE, + "/scim/v2/Users/${user.id}", + "delete-alice", + headers = mapOf("If-Match" to "W/\"1\"") + ) + ) + assertEquals(204, deleted.status) + assertEquals(404, fixture.engine.handle(ScimRequest(ScimHttpMethod.GET, "/scim/v2/Users/${user.id}")).status) + assertNotNull(fixture.identity.snapshot().users.singleOrNull { it.id.value == user.id }) + assertEquals(UserState.ACTIVE, fixture.identity.snapshot().users.single().state) + } + + private fun request( + method: ScimHttpMethod, + path: String, + operationId: String, + body: ByteArray = ByteArray(0), + headers: Map = emptyMap() + ): ScimRequest = ScimRequest( + method = method, + path = path, + headers = headers, + body = body, + operationId = IdentityFixtures.scimOperationId(operationId), + requestId = "request-$operationId" + ) + + private inner class Fixture( + groupMappings: Map = emptyMap() + ) { + val runtime = DeterministicIdentityRuntime() + val organizationId = IdentityFixtures.organizationId("organization-1") + val identity = InMemoryIdentityStore( + InMemoryIdentityStoreSeed(organizations = listOf(IdentityFixtures.organization(organizationId))) + ) + val directory = InMemoryScimDirectory() + val engine = ScimEngine( + identityStore = identity, + directory = directory, + runtime = runtime.runtime, + config = ScimConfig( + organizationId = organizationId, + providerName = "test-directory", + scimBaseUrl = "https://identity.example.test/scim/v2", + groupRoleMappings = groupMappings + ) + ) + + suspend fun createUser(userName: String, externalId: String, operationId: String): ScimUserResource { + val body = json.encodeToString( + ScimUserDocument( + schemas = listOf(ScimSchemas.USER), + externalId = externalId, + userName = userName, + displayName = userName.substringBefore('@') + ) + ).encodeToByteArray() + val response = engine.handle(request(ScimHttpMethod.POST, "/scim/v2/Users", operationId, body)) + assertEquals(201, response.status) + return json.decodeFromString(ScimUserResource.serializer(), response.bodyBytes().decodeToString()) + } + + suspend fun createGroup( + displayName: String, + externalId: String, + memberId: String, + operationId: String + ): ScimGroupResource { + val body = json.encodeToString( + ScimGroupDocument( + schemas = listOf(ScimSchemas.GROUP), + externalId = externalId, + displayName = displayName, + members = listOf(ScimMember(memberId)) + ) + ).encodeToByteArray() + val response = engine.handle(request(ScimHttpMethod.POST, "/scim/v2/Groups", operationId, body)) + assertEquals(201, response.status) + return json.decodeFromString(ScimGroupResource.serializer(), response.bodyBytes().decodeToString()) + } + } +} diff --git a/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimFilterTest.kt b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimFilterTest.kt new file mode 100644 index 0000000..e8ca2fe --- /dev/null +++ b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimFilterTest.kt @@ -0,0 +1,30 @@ +package codes.yousef.aether.auth.scim + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ScimFilterTest { + @Test + fun parsesRequiredEqualityFilter() { + assertEquals( + ScimEqualityFilter("username", "alice@example.test"), + ScimFilterParser.parse("userName eq \"alice@example.test\"") + ) + assertEquals(ScimEqualityFilter("active", "true"), ScimFilterParser.parse("active EQ true")) + } + + @Test + fun rejectsCompoundOrUnsupportedOperators() { + assertFailsWith { ScimFilterParser.parse("userName co \"alice\"") } + assertFailsWith { + ScimFilterParser.parse("userName eq \"alice\" and active eq true") + } + } + + @Test + fun paginationUsesOneBasedStartAndCapsCount() { + val page = parsePage(mapOf("startIndex" to "2", "count" to "99"), maximumPageSize = 2) + assertEquals(listOf(2, 3), page.apply(listOf(1, 2, 3, 4))) + } +} diff --git a/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimHttpMiddlewareTest.kt b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimHttpMiddlewareTest.kt new file mode 100644 index 0000000..8d1a593 --- /dev/null +++ b/aether-auth-scim/src/commonTest/kotlin/codes/yousef/aether/auth/scim/ScimHttpMiddlewareTest.kt @@ -0,0 +1,337 @@ +package codes.yousef.aether.auth.scim + +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.testkit.IdentityFixtures +import codes.yousef.aether.core.Attributes +import codes.yousef.aether.core.Cookie +import codes.yousef.aether.core.Cookies +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.Headers +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.Request +import codes.yousef.aether.core.RequestConnection +import codes.yousef.aether.core.Response +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class ScimHttpMiddlewareTest { + @Test + fun `non SCIM routes fall through without authenticating or reading a body`() = runTest { + var authentications = 0 + var handled = false + val middleware = middleware( + handler = { handled = true; ScimResponse(200) }, + authenticator = { + authentications += 1 + ScimAuthenticationResult.Authenticated(PRINCIPAL) + } + ) + val exchange = TestExchange(HttpMethod.POST, "/application/route", body = "secret".encodeToByteArray()) + var continued = false + + middleware.asMiddleware()(exchange) { continued = true } + + assertTrue(continued) + assertEquals(0, authentications) + assertFalse(handled) + assertEquals(0, exchange.requestValue.bodyReads) + } + + @Test + fun `authentication and tenant authorization fail closed before body IO`() = runTest { + suspend fun execute( + authentication: ScimAuthenticationResult, + authorization: ScimAuthorizationDecision = ScimAuthorizationDecision.ALLOW + ): TestExchange { + val exchange = TestExchange( + HttpMethod.POST, + "/scim/v2/Users", + body = "credential-material".encodeToByteArray() + ) + middleware( + handler = { error("Denied requests must not reach the engine") }, + authenticator = { authentication }, + authorizer = { _, organizationId -> + assertEquals(ORGANIZATION_ID, organizationId) + authorization + } + ).asMiddleware()(exchange) { error("SCIM routes must not fall through") } + assertEquals(0, exchange.requestValue.bodyReads) + return exchange + } + + assertEquals(401, execute(ScimAuthenticationResult.Rejected).response.statusCode) + assertEquals( + 403, + execute( + ScimAuthenticationResult.Authenticated(PRINCIPAL), + ScimAuthorizationDecision.DENY + ).response.statusCode + ) + assertEquals(503, execute(ScimAuthenticationResult.Unavailable).response.statusCode) + assertEquals( + 503, + execute( + ScimAuthenticationResult.Authenticated(PRINCIPAL), + ScimAuthorizationDecision.UNAVAILABLE + ).response.statusCode + ) + } + + @Test + fun `strict mapping forwards stable operation conditional metadata and preserves engine response`() = runTest { + val operationId = IdentityFixtures.scimOperationId("provider-delivery-123").value + val body = """{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"alice"}""" + .encodeToByteArray() + var received: ScimRequest? = null + var authenticatedMetadata: ScimAuthenticationRequest? = null + val expectedBody = byteArrayOf(0x01, 0x23, 0x45) + val middleware = middleware( + handler = { request -> + received = request + ScimResponse( + status = 201, + headers = mapOf( + "Content-Type" to "application/scim+json", + "Location" to "https://identity.example.test/scim/v2/Users/user-1", + "ETag" to "W/\"1\"" + ), + body = expectedBody + ) + }, + authenticator = { metadata -> + authenticatedMetadata = metadata + ScimAuthenticationResult.Authenticated(PRINCIPAL) + } + ) + val exchange = TestExchange( + method = HttpMethod.PATCH, + path = "/scim/v2/Users/user-1", + headers = Headers.of( + "Content-Type" to "application/scim+json; charset=UTF-8", + "Content-Length" to body.size.toString(), + "Idempotency-Key" to operationId, + "X-Request-ID" to "request-123", + "If-Match" to "W/\"7\"", + "User-Agent" to "SCIM Client/1.0", + "Authorization" to "Bearer must-not-be-forwarded" + ), + body = body + ) + + middleware.asMiddleware()(exchange) { error("SCIM routes must not fall through") } + + assertEquals(HttpMethod.PATCH, authenticatedMetadata?.method) + assertEquals("/scim/v2/Users/user-1", authenticatedMetadata?.path) + val request = assertNotNull(received) + assertEquals(ScimHttpMethod.PATCH, request.method) + assertEquals(operationId, request.operationId?.value) + assertEquals("request-123", request.requestId) + assertEquals("W/\"7\"", request.header("if-match")) + assertEquals("SCIM Client/1.0", request.header("user-agent")) + assertEquals(null, request.header("authorization")) + assertContentEquals(body, request.bodyBytes()) + assertEquals(201, exchange.response.statusCode) + assertEquals("application/scim+json", exchange.response.headers.build()["Content-Type"]) + assertEquals("W/\"1\"", exchange.response.headers.build()["ETag"]) + assertContentEquals(expectedBody, exchange.response.bodyBytes()) + } + + @Test + fun `query decoding is strict and duplicate parameters never reach the engine`() = runTest { + var received: ScimRequest? = null + val middleware = middleware(handler = { request -> + received = request + ScimResponse(200, mapOf("Content-Type" to "application/scim+json"), "{}".encodeToByteArray()) + }) + val valid = TestExchange( + method = HttpMethod.GET, + path = "/scim/v2/Users", + query = "filter=userName%20eq%20%22alice%40example.test%22&startIndex=2&count=10", + headers = Headers.of("If-None-Match" to "W/\"4\"") + ) + middleware.asMiddleware()(valid) { error("SCIM routes must not fall through") } + + val mapped = assertNotNull(received) + assertEquals("userName eq \"alice@example.test\"", mapped.query["filter"]) + assertEquals("2", mapped.query["startIndex"]) + assertEquals("10", mapped.query["count"]) + assertEquals("W/\"4\"", mapped.header("If-None-Match")) + + received = null + val duplicate = TestExchange( + method = HttpMethod.GET, + path = "/scim/v2/Users", + query = "count=1&count=2" + ) + middleware.asMiddleware()(duplicate) { error("SCIM routes must not fall through") } + assertEquals(400, duplicate.response.statusCode) + assertEquals(null, received) + assertSafeScimError(duplicate, "400") + } + + @Test + fun `body limits content type method and operation ID are enforced before engine dispatch`() = runTest { + var handled = 0 + val middleware = middleware( + handler = { + handled += 1 + ScimResponse(200) + }, + config = ScimHttpMiddlewareConfig(ORGANIZATION_ID, maximumBodyBytes = 1_024) + ) + val declaredOversize = TestExchange( + HttpMethod.POST, + "/scim/v2/Users", + headers = Headers.of( + "Content-Type" to "application/scim+json", + "Content-Length" to "1025", + "Idempotency-Key" to IdentityFixtures.scimOperationId("oversize-op").value + ), + body = ByteArray(1_025) + ) + middleware.asMiddleware()(declaredOversize) { error("SCIM routes must not fall through") } + assertEquals(413, declaredOversize.response.statusCode) + assertEquals(0, declaredOversize.requestValue.bodyReads) + + val observedOversize = TestExchange( + HttpMethod.POST, + "/scim/v2/Users", + headers = Headers.of( + "Content-Type" to "application/scim+json", + "Idempotency-Key" to IdentityFixtures.scimOperationId("observed-op").value + ), + body = ByteArray(1_025) + ) + middleware.asMiddleware()(observedOversize) { error("SCIM routes must not fall through") } + assertEquals(413, observedOversize.response.statusCode) + assertEquals(1, observedOversize.requestValue.bodyReads) + + val missingOperation = TestExchange( + HttpMethod.POST, + "/scim/v2/Users", + headers = Headers.of("Content-Type" to "application/scim+json"), + body = "{}".encodeToByteArray() + ) + middleware.asMiddleware()(missingOperation) { error("SCIM routes must not fall through") } + assertEquals(400, missingOperation.response.statusCode) + assertEquals(0, missingOperation.requestValue.bodyReads) + + val wrongMedia = TestExchange( + HttpMethod.POST, + "/scim/v2/Users", + headers = Headers.of( + "Content-Type" to "application/json", + "Idempotency-Key" to IdentityFixtures.scimOperationId("wrong-media-op").value + ), + body = "{}".encodeToByteArray() + ) + middleware.asMiddleware()(wrongMedia) { error("SCIM routes must not fall through") } + assertEquals(415, wrongMedia.response.statusCode) + assertEquals(0, wrongMedia.requestValue.bodyReads) + + val unsupported = TestExchange(HttpMethod.HEAD, "/scim/v2/Users") + middleware.asMiddleware()(unsupported) { error("SCIM routes must not fall through") } + assertEquals(405, unsupported.response.statusCode) + assertEquals(0, unsupported.requestValue.bodyReads) + assertEquals(0, handled) + } + + @Test + fun `provider failures become generic SCIM unavailable errors`() = runTest { + val exchange = TestExchange(HttpMethod.GET, "/scim/v2/Users") + middleware(handler = { throw IllegalStateException("database password and internal details") }) + .asMiddleware()(exchange) { error("SCIM routes must not fall through") } + + assertEquals(503, exchange.response.statusCode) + assertSafeScimError(exchange, "503") + assertFalse(exchange.response.bodyText().contains("database", ignoreCase = true)) + assertFalse(exchange.response.bodyText().contains("password", ignoreCase = true)) + } + + private fun middleware( + handler: suspend (ScimRequest) -> ScimResponse, + authenticator: suspend (ScimAuthenticationRequest) -> ScimAuthenticationResult = { + ScimAuthenticationResult.Authenticated(PRINCIPAL) + }, + authorizer: suspend (ScimClientPrincipal, OrganizationId) -> ScimAuthorizationDecision = { _, _ -> + ScimAuthorizationDecision.ALLOW + }, + config: ScimHttpMiddlewareConfig = ScimHttpMiddlewareConfig(ORGANIZATION_ID) + ) = ScimHttpMiddleware( + handler = ScimRequestHandler(handler), + authenticator = ScimAuthenticator(authenticator), + authorizer = ScimTenantAuthorizer(authorizer), + config = config + ) + + private companion object { + val ORGANIZATION_ID = IdentityFixtures.organizationId("organization-1") + val PRINCIPAL = ScimClientPrincipal("scim-client-1") + } +} + +private class TestRequest( + override val method: HttpMethod, + override val path: String, + override val query: String?, + override val headers: Headers, + private val body: ByteArray +) : Request { + override val uri: String = if (query == null) path else "$path?$query" + override val cookies: Cookies = Cookies.Empty + override val connection: RequestConnection = RequestConnection("https", "identity.example.test", "127.0.0.1") + var bodyReads: Int = 0 + private set + + override suspend fun bodyBytes(): ByteArray { + bodyReads += 1 + return body.copyOf() + } +} + +private class TestResponse : Response { + override var statusCode: Int = 200 + override var statusMessage: String? = null + override val headers = Headers.HeadersBuilder() + override val cookies = mutableListOf() + private val body = mutableListOf() + + override suspend fun write(data: ByteArray) { body += data.toList() } + override suspend fun end() = Unit + fun bodyBytes(): ByteArray = body.toByteArray() + fun bodyText(): String = bodyBytes().decodeToString() +} + +private class TestExchange( + method: HttpMethod, + path: String, + query: String? = null, + headers: Headers = Headers.Empty, + body: ByteArray = ByteArray(0) +) : Exchange { + val requestValue = TestRequest(method, path, query, headers, body) + override val request: Request = requestValue + override val response = TestResponse() + override val attributes = Attributes() +} + +private fun assertSafeScimError(exchange: TestExchange, expectedStatus: String) { + assertEquals("application/scim+json", exchange.response.headers.build()["Content-Type"]) + val payload = Json.parseToJsonElement(exchange.response.bodyText()).jsonObject + assertEquals( + listOf(ScimSchemas.ERROR), + payload.getValue("schemas").jsonArray.map { it.jsonPrimitive.content } + ) + assertEquals(expectedStatus, payload.getValue("status").jsonPrimitive.content) + assertFalse(payload.getValue("detail").jsonPrimitive.content.isBlank()) +} diff --git a/aether-auth-summon/README.md b/aether-auth-summon/README.md new file mode 100644 index 0000000..13a83d8 --- /dev/null +++ b/aether-auth-summon/README.md @@ -0,0 +1,45 @@ +# Aether Auth Summon + +`aether-auth-summon` is the optional passkey-first identity UI. It provides small, data-driven +Summon components in `commonMain`, a JVM SSR renderer, and a wasmJs hydration/browser credential +adapter. + +The module intentionally has no wasmWasi target. A wasmWasi identity authority exposes the same +`/identity/v1` JSON APIs from `aether-auth`; it does not embed browser components or +`navigator.credentials`. + +The common component set also includes organization selection, membership and invitation +management, service-identity and public credential-prefix management, and RFC 8628 device +approval. Signed-in users enter the short human code into a bounded POST-backed form; the code is +never sourced from a URL. Device approval always requires a separate explicit organization selection and at +least one requested capability; it never inherits the organization selected in the management +panel. +Each offered device organization carries its own intersection of requested and approvable +capabilities. Changing the organization clears the scope selection, and scopes not granted for +that organization cannot be selected. + +## Security boundary + +- Browser code receives only public WebAuthn creation/request options and returns the standard + browser credential envelope plus its opaque ceremony ID through the host gateway. +- `IdentityRuntime`, identity stores, authority keys, secret references, token digests, session + cookies, and administrative enrollment-ticket secrets are not accepted by browser-facing APIs. +- Recovery codes are intentional one-time user-facing secrets. The UI displays exactly ten in a + focused live region and removes them when `DismissRecoveryCodes` is handled. The recovery-code + state redacts its string representation. +- JVM SSR does not use Summon's generic hydration-state serializer. The host supplies the same + browser-safe UI state when hydrating. +- Organization resource models and mutation actions carry an explicit organization ID. The UI + state rejects memberships, invitations, or service identities that do not belong to the + selected organization, so hosts do not need a server-side "selected organization" session. +- Device approval state contains the human verification code, organization choices, and requested + public capability names only. It does not contain the device code, access/refresh tokens, token + digests, or the server-side grant object; diagnostics redact the human code as well. Manual entry + normalizes the eight-character code locally and resolves it through JSON `POST /identity/v1/device`. +- Full-width roots, panels, flex children, lists, and controls use border-box sizing with a zero + minimum width. Buttons reserve Summon's inline margin, preventing the identity surface from + exceeding a 390px phone viewport. + +Applications own routing and JSON transport. Implement `PasskeyCeremonyGateway`, inject a +`PasskeyBrowserClient` (use `NavigatorCredentialsPasskeyClient` on wasmJs), and dispatch the +`IdentityUiAction` values to their state holder. diff --git a/aether-auth-summon/build.gradle.kts b/aether-auth-summon/build.gradle.kts new file mode 100644 index 0000000..1c8f7fc --- /dev/null +++ b/aether-auth-summon/build.gradle.kts @@ -0,0 +1,30 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvm { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + testRuns["test"].executionTask.configure { useJUnitPlatform() } + } + wasmJs { + browser() + nodejs() + } + + sourceSets { + commonMain.dependencies { + api(project(":aether-auth")) + api(libs.summon) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/aether-auth-summon/gradle.lockfile b/aether-auth-summon/gradle.lockfile new file mode 100644 index 0000000..24adac3 --- /dev/null +++ b/aether-auth-summon/gradle.lockfile @@ -0,0 +1,389 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +ch.qos.logback:logback-classic:1.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +ch.qos.logback:logback-core:1.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +codes.yousef:summon-core:0.7.0.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +codes.yousef:summon-jvm:0.7.0.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +codes.yousef:summon:0.7.0.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +com.aayushatharva.brotli4j:brotli4j:1.16.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.aayushatharva.brotli4j:service:1.16.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson.module:jackson-module-parameter-names:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.19.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.ibm.icu:icu4j:72.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.openhtmltopdf:openhtmltopdf-core:1.0.10=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.openhtmltopdf:openhtmltopdf-rtl-support:1.0.10=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.typesafe:config:1.4.5=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-all:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-abbreviation:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-admonition:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-anchorlink:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-aside:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-attributes:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-autolink:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-definition:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-emoji:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-enumerated-reference:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-escaped-character:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-footnotes:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-gfm-issues:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-gfm-tasklist:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-gfm-users:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-gitlab:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-ins:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-jekyll-tag:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-macros:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-media-tags:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-resizable-image:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-superscript:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-tables:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-toc:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-typographic:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-wikilink:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-xwiki-macros:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-yaml-front-matter:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-ext-youtube-embedded:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-html2md-converter:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-jira-converter:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-pdf-converter:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-profile-pegdown:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-ast:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-builder:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-collection:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-data:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-dependency:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-format:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-html:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-misc:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-options:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-sequence:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util-visitor:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-util:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark-youtrack-converter:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +com.vladsch.flexmark:flexmark:0.64.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +commons-logging:commons-logging:1.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +de.rototor.pdfbox:graphics2d:0.32=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.crac:org-crac:0.1.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +io.ktor:ktor-events-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-events:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-http-cio-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-http-cio:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-http-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-http:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-io-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-io:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-network-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-network:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-serialization-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-json:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-serialization-kotlinx:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-serialization:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-content-negotiation-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-content-negotiation:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-core-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-core:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-html-builder-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-html-builder:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-netty-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-server-netty:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-utils-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-utils:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-websockets-jvm:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.ktor:ktor-websockets:3.3.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.micrometer:micrometer-commons:1.14.14=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.micrometer:micrometer-observation:1.14.14=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-buffer:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-base:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-compression:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-dns:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-haproxy:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http2:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-http:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec-socks:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-codec:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-common:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler-proxy:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-handler:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver-dns:4.1.128.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-resolver:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-classes-kqueue:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-kqueue:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.netty:netty-transport:4.2.7.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.projectreactor.kotlin:reactor-kotlin-extensions:1.2.5=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.2.13=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.2.13=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.projectreactor:reactor-core:3.7.14=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.arc:arc-processor:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.arc:arc:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.gizmo:gizmo:1.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.http:quarkus-http-websocket-core:5.3.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.http:quarkus-http-websocket-vertx:5.3.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.qute:qute-core:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.resteasy.reactive:resteasy-reactive-common-types:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.resteasy.reactive:resteasy-reactive-common:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.resteasy.reactive:resteasy-reactive-jackson:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.resteasy.reactive:resteasy-reactive-vertx:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.resteasy.reactive:resteasy-reactive:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.security:quarkus-security:2.2.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus.vertx.utils:quarkus-vertx-utils:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-arc-deployment:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-arc-test-supplement:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-arc:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-bootstrap-app-model:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-bootstrap-core:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-bootstrap-runner:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-builder:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-class-change-agent:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-classloader-commons:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-core-deployment:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-core:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-credentials:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-development-mode-spi:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-devtools-utilities:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-fs-util:0.0.10=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-hibernate-validator-spi:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-ide-launcher:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-jackson:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-jsonp:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-kotlin:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-mutiny:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-netty:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-qute:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-rest-common:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-rest-jackson-common:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-rest-jackson:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-rest:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-security-deployment:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-security-runtime-spi:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-security-spi:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-security:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-smallrye-context-propagation-spi:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-smallrye-context-propagation:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-tls-registry:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-vertx-http-dev-ui-spi:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-vertx-http:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-vertx-latebound-mdc-provider:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-vertx:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-virtual-threads:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-websockets-client:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.quarkus:quarkus-websockets:3.20.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.certs:smallrye-private-key-pem-parser:0.9.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-annotation:2.13.8=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-classloader:2.10.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-constraint:2.12.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-cpu:2.6.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-expression:2.10.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-function:2.10.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-io:2.12.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-net:2.4.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-os:2.12.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-ref:2.4.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.common:smallrye-common-vertx-context:2.12.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.config:smallrye-config-common:3.11.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.config:smallrye-config-core:3.11.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.config:smallrye-config:3.11.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:mutiny-kotlin:2.9.5=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:mutiny-smallrye-context-propagation:2.9.5=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:mutiny-zero-flow-adapters:1.1.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:mutiny:2.9.5=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-auth-common:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-bridge-common:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-core:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-runtime:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-uri-template:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-web-common:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:smallrye-mutiny-vertx-web:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye.reactive:vertx-mutiny-generator:3.19.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye:jandex:3.3.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye:smallrye-context-propagation-api:2.2.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye:smallrye-context-propagation-storage:2.2.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye:smallrye-context-propagation:2.2.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye:smallrye-fault-tolerance-kotlin:6.9.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.smallrye:smallrye-fault-tolerance-vertx:6.9.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-auth-common:4.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-bridge-common:4.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-codegen:4.5.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-core:4.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-lang-kotlin-coroutines:4.5.11=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-uri-template:4.5.16=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-web-common:4.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +io.vertx:vertx-web:4.5.22=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.el:jakarta.el-api:6.0.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.enterprise:jakarta.enterprise.cdi-api:4.1.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.enterprise:jakarta.enterprise.lang-model:4.1.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.interceptor:jakarta.interceptor-api:2.2.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.json:jakarta.json-api:2.1.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.1.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.websocket:jakarta.websocket-client-api:2.1.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.ws.rs:jakarta.ws.rs-api:3.1.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.aesh:aesh:2.8.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.aesh:readline:2.6=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.commons:commons-lang3:3.18.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.24.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.24.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.pdfbox:fontbox:2.0.24=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.pdfbox:pdfbox:2.0.24=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.pdfbox:xmpbox:2.0.24=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:10.1.50=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:10.1.50=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:10.1.50=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jvmTestCompileClasspath +org.attoparser:attoparser:2.0.7.RELEASE=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.eclipse.jetty.alpn:alpn-api:1.1.3.v20160715=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.eclipse.microprofile.config:microprofile-config-api:3.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.eclipse.microprofile.context-propagation:microprofile-context-propagation-api:1.3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.eclipse.parsson:parsson:1.1.7=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.eclipse.sisu:org.eclipse.sisu.inject:0.9.0.M3=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.fusesource.jansi:jansi:2.4.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.graalvm.sdk:nativeimage:23.1.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.graalvm.sdk:word:23.1.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jboss.logging:commons-logging-jboss-logging:1.0.0.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jboss.logging:jboss-logging-annotations:3.0.4.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jboss.logmanager:jboss-logmanager:3.1.2.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jboss.slf4j:slf4j-jboss-logmanager:2.0.0.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jboss.threads:jboss-threads:3.8.0.Final=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jctools:jctools-core:4.0.5=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-reflect:2.2.21=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.0=wasmJsNpmAggregated,wasmJsTestNpmAggregated +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-junit5:2.3.21=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsTestCompileClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.30.0-beta=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmMainResolvableDependenciesMetadata,jvmTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=wasmJsCompileClasspath,wasmJsTestCompileClasspath +org.jetbrains.kotlinx:atomicfu:0.30.0-beta=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-browser-wasm-js:0.5.0=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-browser:0.5.0=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.10.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.10.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.10.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.10.2=jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-html-jvm:0.12.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-html-wasm-js:0.12.0=wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-html:0.12.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-io-core:0.8.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor-wasm-js:1.9.0=wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath,wasmJsRuntimeClasspath,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.9.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jvmCompileClasspath,jvmMainCompileClasspath,jvmMainResolvableDependenciesMetadata,jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestResolvableDependenciesMetadata,jvmTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJvmMain,kotlinCompilerPluginClasspathJvmTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=jvmCompileClasspath,jvmMainCompileClasspath,jvmTestCompileClasspath +org.jetbrains:annotations:26.0.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.jsoup:jsoup:1.15.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:5.10.1=jvmTestCompileClasspath +org.junit.jupiter:junit-jupiter-api:5.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.platform:junit-platform-commons:1.10.1=jvmTestCompileClasspath +org.junit.platform:junit-platform-commons:1.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.platform:junit-platform-engine:1.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.junit:junit-bom:5.10.1=jvmTestCompileClasspath +org.junit:junit-bom:5.12.2=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.nibor.autolink:autolink:0.6.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestCompileClasspath,jvmTestRuntimeClasspath +org.ow2.asm:asm-analysis:9.6=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.ow2.asm:asm-commons:9.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.ow2.asm:asm-tree:9.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.ow2.asm:asm-util:9.6=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.ow2.asm:asm:9.7.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-reactor-netty:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-thymeleaf:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-webflux:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot-starter:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework.boot:spring-boot:3.5.9=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-aop:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-beans:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-context:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-core:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-expression:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-jcl:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-web:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-webflux:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.springframework:spring-webmvc:6.2.15=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.thymeleaf:thymeleaf-spring6:3.1.3.RELEASE=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.thymeleaf:thymeleaf:3.1.3.RELEASE=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.unbescape:unbescape:1.1.6.RELEASE=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.wildfly.common:wildfly-common:2.0.1=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +org.yaml:snakeyaml:2.4=jvmMainRuntimeClasspath,jvmRuntimeClasspath,jvmTestRuntimeClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jvmMainAnnotationProcessor,jvmMainImplementationDependenciesMetadata,jvmTestAnnotationProcessor,jvmTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDefExtensions,testKotlinScriptDefExtensions,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-auth-summon/karma.config.d/ci.js b/aether-auth-summon/karma.config.d/ci.js new file mode 100644 index 0000000..822e126 --- /dev/null +++ b/aether-auth-summon/karma.config.d/ci.js @@ -0,0 +1,14 @@ +if (process.env.CI === "true") { + config.set({ + browsers: ["ChromeHeadlessNoSandbox"], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: "ChromeHeadless", + flags: [ + "--no-sandbox", + "--disable-dev-shm-usage", + ], + }, + }, + }); +} diff --git a/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityComponents.kt b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityComponents.kt new file mode 100644 index 0000000..83d6178 --- /dev/null +++ b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityComponents.kt @@ -0,0 +1,1226 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.InvitationState +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.ServiceCredentialState +import codes.yousef.aether.auth.ServiceIdentityState +import codes.yousef.summon.annotation.Composable +import codes.yousef.summon.components.display.Label +import codes.yousef.summon.components.display.Text +import codes.yousef.summon.components.html.Code +import codes.yousef.summon.components.html.H1 +import codes.yousef.summon.components.html.H2 +import codes.yousef.summon.components.html.H3 +import codes.yousef.summon.components.html.Li +import codes.yousef.summon.components.html.Main +import codes.yousef.summon.components.html.P +import codes.yousef.summon.components.html.Section +import codes.yousef.summon.components.html.Ul +import codes.yousef.summon.components.input.Button +import codes.yousef.summon.components.input.ButtonVariant +import codes.yousef.summon.components.input.TextField +import codes.yousef.summon.components.layout.Column +import codes.yousef.summon.modifier.AlignItems +import codes.yousef.summon.modifier.BorderStyle +import codes.yousef.summon.modifier.Display +import codes.yousef.summon.modifier.FlexDirection +import codes.yousef.summon.modifier.MediaQuery +import codes.yousef.summon.modifier.Modifier +import codes.yousef.summon.modifier.alignItems +import codes.yousef.summon.modifier.ariaAttribute +import codes.yousef.summon.modifier.ariaChecked +import codes.yousef.summon.modifier.ariaDescribedBy +import codes.yousef.summon.modifier.ariaInvalid +import codes.yousef.summon.modifier.ariaLabel +import codes.yousef.summon.modifier.ariaLabelledBy +import codes.yousef.summon.modifier.autoFocus +import codes.yousef.summon.modifier.backgroundColor +import codes.yousef.summon.modifier.border +import codes.yousef.summon.modifier.borderRadius +import codes.yousef.summon.modifier.borderColor +import codes.yousef.summon.modifier.borderBottomWidth +import codes.yousef.summon.modifier.borderTopWidth +import codes.yousef.summon.modifier.borderStyle +import codes.yousef.summon.modifier.borderWidth +import codes.yousef.summon.modifier.color +import codes.yousef.summon.modifier.dataAttribute +import codes.yousef.summon.modifier.display +import codes.yousef.summon.modifier.fillMaxWidth +import codes.yousef.summon.modifier.flex +import codes.yousef.summon.modifier.flexDirection +import codes.yousef.summon.modifier.fontSize +import codes.yousef.summon.modifier.fontWeight +import codes.yousef.summon.modifier.gap +import codes.yousef.summon.modifier.id +import codes.yousef.summon.modifier.margin +import codes.yousef.summon.modifier.maxWidth +import codes.yousef.summon.modifier.mediaQuery +import codes.yousef.summon.modifier.minWidth +import codes.yousef.summon.modifier.padding +import codes.yousef.summon.modifier.role +import codes.yousef.summon.modifier.style +import codes.yousef.summon.modifier.tabIndex + +private object IdentityUiIds { + const val ROOT = "aether-identity" + const val REGISTRATION_NAME = "aether-registration-name" + const val REGISTRATION_HELP = "aether-registration-help" + const val FEEDBACK = "aether-identity-feedback" + const val RECOVERY_CODES = "aether-recovery-codes" + const val ONE_TIME_SECRET = "aether-one-time-identity-secret" + const val ADMIN_RECOVERY_USER = "aether-admin-recovery-user" + const val ORGANIZATION_SELECTION = "aether-organization-selection" + const val INVITATION_EMAIL = "aether-invitation-email" + const val INVITATION_ROLE = "aether-invitation-role" + const val SERVICE_IDENTITY_NAME = "aether-service-identity-name" + const val SERVICE_IDENTITY_DESCRIPTION = "aether-service-identity-description" + const val SERVICE_IDENTITY_SCOPES = "aether-service-identity-scopes" + const val DEVICE_USER_CODE = "aether-device-user-code" + const val DEVICE_USER_CODE_HELP = "aether-device-user-code-help" + const val DEVICE_APPROVAL = "aether-device-approval" + const val DEVICE_ORGANIZATION = "aether-device-organization" + const val DEVICE_SCOPES = "aether-device-scopes" +} + +/** Complete, mobile-first identity surface shared by JVM SSR and wasmJs hydration. */ +@Composable +fun IdentityUi( + state: IdentityUiState, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Main( + modifier = identityRootModifier(modifier) + .id(IdentityUiIds.ROOT) + .ariaLabel("Aether identity") + ) { + H1(modifier = headingModifier()) { Text("Identity and security") } + P { Text("Use passkeys to sign in and protect this account. Passwords are not supported.") } + + IdentityFeedback(state.feedback) + + Column(modifier = identityResponsiveColumnsModifier()) { + IdentityEntryPanel(state.registration, state.busyAction, dispatcher) + if (state.signedInDisplayName != null) { + IdentityManagementPanel(state, dispatcher) + } + } + } +} + +@Composable +fun IdentityEntryPanel( + state: RegistrationUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Column(modifier = panelModifier(Modifier().flex(1, 1, "0").then(modifier)).ariaLabel("Passkey access")) { + H2(modifier = headingModifier()) { Text("Passkey access") } + + val registrationLabelId = "${IdentityUiIds.REGISTRATION_NAME}-label" + Label( + "Passkey name", + modifier = Modifier().id(registrationLabelId), + forElement = IdentityUiIds.REGISTRATION_NAME + ) + TextField( + value = state.passkeyName, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangeRegistrationName(it)) }, + label = "Passkey name", + placeholder = "For example, Personal security key", + isEnabled = state.enabled && busyAction == null, + modifier = Modifier() + .id(IdentityUiIds.REGISTRATION_NAME) + .ariaLabel("Passkey name") + .ariaLabelledBy(registrationLabelId) + .ariaDescribedBy(IdentityUiIds.REGISTRATION_HELP) + .responsiveControl() + ) + P(modifier = Modifier().id(IdentityUiIds.REGISTRATION_HELP)) { + Text("Choose a name that helps you recognize this passkey later.") + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.RegisterPasskey) }, + label = if (busyAction == IdentityUiActionKind.REGISTER_PASSKEY) "Creating passkey…" else "Create passkey", + disabled = !state.enabled || state.passkeyName.isBlank() || busyAction != null, + dataAttributes = mapOf("identity-action" to "register-passkey"), + modifier = actionButtonModifier("Create a passkey named ${state.passkeyName.ifBlank { "new passkey" }}") + ) + if (state.signInEnabled) { + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.DiscoverableSignIn) }, + label = if (busyAction == IdentityUiActionKind.SIGN_IN) "Signing in…" else "Sign in with a passkey", + variant = ButtonVariant.SECONDARY, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "discoverable-sign-in"), + modifier = actionButtonModifier("Sign in with a discoverable passkey") + ) + } + } +} + +@Composable +fun IdentityManagementPanel( + state: IdentityUiState, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Column(modifier = Modifier().responsiveContainer().flex(2, 1, "0").gap("1rem").then(modifier)) { + H2(modifier = headingModifier()) { Text("Security for ${state.signedInDisplayName}") } + if (state.deviceApproval == null) { + DeviceAuthorizationPanel(state.deviceAuthorization, state.busyAction, dispatcher) + } else { + DeviceApprovalPanel(state.deviceApproval, state.busyAction, dispatcher) + } + OneTimeIdentitySecretPanel(state.oneTimeSecret, dispatcher) + if (state.organizationManagement.organizations.isNotEmpty()) { + OrganizationManagementPanel(state.organizationManagement, state.busyAction, dispatcher) + } + PasskeyManagementPanel(state.passkeys, state.busyAction, dispatcher) + SessionManagementPanel(state.sessions, state.busyAction, dispatcher) + RecoveryCodesPanel(state.recoveryCodes, state.busyAction, dispatcher) + AdministrativeRecoveryPanel(state.administrativeRecovery, state.busyAction, dispatcher) + StepUpPanel(state.stepUp, state.busyAction, dispatcher) + } +} + +@Composable +fun PasskeyManagementPanel( + passkeys: List, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Passkey management")) { + H3(modifier = headingModifier()) { Text("Your passkeys") } + if (passkeys.isEmpty()) { + P { Text("No passkeys are enrolled.") } + } else { + Ul(modifier = listModifier()) { + passkeys.forEach { passkey -> + PasskeyRow(passkey, busyAction, dispatcher) + } + } + } + } +} + +@Composable +fun PasskeyRow( + passkey: PasskeyUiModel, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + val inputId = "passkey-name-${passkey.id.value}" + val descriptionId = "passkey-description-${passkey.id.value}" + Li(modifier = listItemModifier(modifier).dataAttribute("credential-id", passkey.id.value)) { + val labelId = "$inputId-label" + Label("Name for ${passkey.name}", modifier = Modifier().id(labelId), forElement = inputId) + TextField( + value = passkey.renameDraft, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangePasskeyName(passkey.id, it)) }, + label = "Passkey name", + isEnabled = busyAction == null, + modifier = Modifier() + .id(inputId) + .ariaLabel("Name for passkey ${passkey.name}") + .ariaLabelledBy(labelId) + .ariaDescribedBy(descriptionId) + .responsiveControl() + ) + P(modifier = Modifier().id(descriptionId)) { + val backup = if (passkey.backedUp) "Synced passkey." else "Device-bound or backup status unavailable." + val lastUsed = passkey.lastUsedAt?.let { " Last used $it." } ?: " Not used yet." + Text("Created ${passkey.createdAt}. $backup$lastUsed") + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.RenamePasskey(passkey.id)) }, + label = "Save name", + variant = ButtonVariant.SECONDARY, + disabled = busyAction != null || passkey.renameDraft.isBlank() || passkey.renameDraft == passkey.name, + dataAttributes = mapOf("identity-action" to "rename-passkey"), + modifier = actionButtonModifier("Save the new name for ${passkey.name}") + ) + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.RevokePasskey(passkey.id)) }, + label = "Revoke passkey", + variant = ButtonVariant.DANGER, + disabled = busyAction != null || !passkey.canRevoke, + dataAttributes = mapOf("identity-action" to "revoke-passkey"), + modifier = actionButtonModifier("Revoke passkey ${passkey.name}") + ) + } +} + +@Composable +fun SessionManagementPanel( + sessions: List, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Session and device management")) { + H3(modifier = headingModifier()) { Text("Sessions and devices") } + if (sessions.isEmpty()) { + P { Text("No active sessions.") } + } else { + Ul(modifier = listModifier()) { + sessions.forEach { session -> + Li(modifier = listItemModifier().dataAttribute("session-id", session.id.value)) { + Text( + if (session.current) "${session.deviceLabel} — this device" else session.deviceLabel, + modifier = Modifier().fontWeight(600) + ) + P { + val recent = if (session.recentPasskey) " Recent passkey verification." else "" + Text("Last active ${session.lastUsedAt}; expires ${session.expiresAt}.$recent") + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.RevokeSession(session.id)) }, + label = if (session.current) "Sign out this device" else "Revoke device", + variant = ButtonVariant.DANGER, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "revoke-session"), + modifier = actionButtonModifier( + if (session.current) "Sign out this device" else "Revoke ${session.deviceLabel}" + ) + ) + } + } + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.RevokeOtherSessions) }, + label = "Revoke other devices", + variant = ButtonVariant.DANGER, + disabled = busyAction != null || sessions.none { !it.current }, + dataAttributes = mapOf("identity-action" to "revoke-other-sessions"), + modifier = actionButtonModifier("Revoke every session except this device") + ) + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.RevokeAllSessions) }, + label = "Revoke all sessions", + variant = ButtonVariant.DANGER, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "revoke-all-sessions"), + modifier = actionButtonModifier("Revoke all sessions, including this device") + ) + } + } +} + +@Composable +fun RecoveryCodesPanel( + state: RecoveryCodesUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Recovery codes")) { + H3(modifier = headingModifier()) { Text("Recovery codes") } + when (state) { + RecoveryCodesUiState.Hidden -> { + P { Text("Generate ten single-use codes for account recovery. Existing unused codes will stop working.") } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.GenerateRecoveryCodes) }, + label = if (busyAction == IdentityUiActionKind.GENERATE_RECOVERY_CODES) { + "Generating recovery codes…" + } else { + "Generate recovery codes" + }, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "generate-recovery-codes"), + modifier = actionButtonModifier("Generate a new set of recovery codes") + ) + } + + is RecoveryCodesUiState.VisibleOnce -> { + Column( + modifier = Modifier() + .id(IdentityUiIds.RECOVERY_CODES) + .role("region") + .ariaLabel("New recovery codes") + .ariaAttribute("live", "assertive") + .tabIndex(-1) + .autoFocus() + .responsiveContainer() + .padding("1rem") + .backgroundColor("#fff8dc") + .border("2px", "solid", "#8a5a00") + .borderRadius("0.5rem") + ) { + Text("Save these codes now. They will not be shown again.", modifier = Modifier().fontWeight(700)) + Ul(modifier = listModifier()) { + state.codes.forEachIndexed { index, code -> + Li { + Code(modifier = Modifier().ariaLabel("Recovery code ${index + 1}")) { + Text(code) + } + } + } + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.DismissRecoveryCodes) }, + label = "I saved these codes", + dataAttributes = mapOf("identity-action" to "dismiss-recovery-codes"), + modifier = actionButtonModifier("Confirm that the recovery codes are saved") + ) + } + } + } + } +} + +@Composable +fun OneTimeIdentitySecretPanel( + state: OneTimeIdentitySecretUiState, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + if (state !is OneTimeIdentitySecretUiState.VisibleOnce) return + Section( + modifier = panelModifier(modifier) + .id(IdentityUiIds.ONE_TIME_SECRET) + .role("region") + .ariaLabel("New one-time identity secret") + .ariaAttribute("live", "assertive") + .tabIndex(-1) + .autoFocus() + ) { + H3(modifier = headingModifier()) { + Text( + when (state.kind) { + OneTimeIdentitySecretKind.INVITATION_TOKEN -> "New invitation token" + OneTimeIdentitySecretKind.SERVICE_CREDENTIAL -> "New service credential" + } + ) + } + P { Text("${state.label}. Copy this value now. It will not be shown again after dismissal or navigation.") } + Code(modifier = Modifier().ariaLabel("One-time secret value")) { Text(state.secret) } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.DismissOneTimeSecret) }, + label = "I saved this value", + dataAttributes = mapOf("identity-action" to "dismiss-one-time-secret"), + modifier = actionButtonModifier("Confirm that the one-time identity secret is saved") + ) + } +} + +@Composable +fun AdministrativeRecoveryPanel( + state: AdministrativeRecoveryUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Administrative recovery")) { + H3(modifier = headingModifier()) { Text("Administrative recovery") } + if (!state.enabled) { + P { Text("Administrative recovery is not configured.") } + return@Section + } + + val administrativeRecoveryLabelId = "${IdentityUiIds.ADMIN_RECOVERY_USER}-label" + Label( + "User ID", + modifier = Modifier().id(administrativeRecoveryLabelId), + forElement = IdentityUiIds.ADMIN_RECOVERY_USER + ) + TextField( + value = state.userQuery, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangeAdministrativeRecoveryUser(it)) }, + label = "User ID", + placeholder = "018f47d2-8d4d-7abc-8def-1234567890ab", + isEnabled = busyAction == null && state.outstandingTicket == null, + modifier = Modifier() + .id(IdentityUiIds.ADMIN_RECOVERY_USER) + .ariaLabel("User ID for administrative recovery") + .ariaLabelledBy(administrativeRecoveryLabelId) + .ariaInvalid(state.userQuery.isBlank()) + .responsiveControl() + ) + state.deliveryStatus?.let { + Text(it, modifier = Modifier().role("status").ariaAttribute("live", "polite")) + } + + val ticket = state.outstandingTicket + if (ticket == null) { + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.IssueAdministrativeRecovery) }, + label = if (busyAction == IdentityUiActionKind.ADMINISTRATIVE_RECOVERY) { + "Issuing enrollment link…" + } else { + "Issue enrollment link" + }, + disabled = busyAction != null || state.userQuery.isBlank(), + dataAttributes = mapOf("identity-action" to "issue-administrative-recovery"), + modifier = actionButtonModifier("Issue a single-use passkey enrollment link") + ) + } else { + P { Text("Enrollment link ${ticket.id.value} expires ${ticket.expiresAt}.") } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.CancelAdministrativeRecovery(ticket.id)) }, + label = "Cancel enrollment link", + variant = ButtonVariant.DANGER, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "cancel-administrative-recovery"), + modifier = actionButtonModifier("Cancel the outstanding enrollment link") + ) + } + } +} + +@Composable +fun StepUpPanel( + state: StepUpUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Recent passkey verification")) { + H3(modifier = headingModifier()) { Text("Recent passkey verification") } + when { + state.satisfiedAt != null -> P { Text("Verified with a passkey at ${state.satisfiedAt}.") } + state.required -> P { Text(state.reason ?: "Verify with a passkey before continuing.") } + else -> P { Text("Some sensitive actions require a passkey used within the last five minutes.") } + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.StepUpWithPasskey) }, + label = if (busyAction == IdentityUiActionKind.STEP_UP) "Verifying…" else "Verify with passkey", + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "step-up"), + modifier = actionButtonModifier("Verify this session with a passkey") + ) + } +} + +@Composable +fun OrganizationManagementPanel( + state: OrganizationManagementUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Organization identity management")) { + H3(modifier = headingModifier()) { Text("Organizations") } + P { Text("Choose an organization before managing its members, invitations, or service identities.") } + OrganizationSelection( + organizations = state.organizations, + selectedOrganizationId = state.selectedOrganizationId, + enabled = busyAction == null, + dispatcher = dispatcher + ) + + val selected = state.organizations.firstOrNull { it.id == state.selectedOrganizationId } + if (selected == null) { + P(modifier = Modifier().role("status")) { Text("No organization selected.") } + return@Section + } + + P(modifier = Modifier().fontWeight(600)) { + Text("Managing ${selected.name} as ${roleLabel(selected.role)}.") + } + MembershipManagement( + memberships = state.memberships, + busyAction = busyAction, + dispatcher = dispatcher + ) + InvitationManagement( + organizationId = selected.id, + draft = state.invitationDraft, + invitations = state.invitations, + canManage = state.canInviteMembers, + busyAction = busyAction, + dispatcher = dispatcher + ) + ServiceIdentityManagement( + organizationId = selected.id, + draft = state.serviceIdentityDraft, + serviceIdentities = state.serviceIdentities, + canManage = state.canManageServiceIdentities, + busyAction = busyAction, + dispatcher = dispatcher + ) + } +} + +@Composable +fun OrganizationSelection( + organizations: List, + selectedOrganizationId: OrganizationId?, + enabled: Boolean, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Column( + modifier = Modifier() + .id(IdentityUiIds.ORGANIZATION_SELECTION) + .role("radiogroup") + .ariaLabel("Organization selection") + .responsiveContainer() + .gap("0.5rem") + .then(modifier) + ) { + organizations.forEach { organization -> + val selected = organization.id == selectedOrganizationId + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.SelectOrganization(organization.id)) }, + label = "${organization.name} — ${roleLabel(organization.role)}", + variant = ButtonVariant.SECONDARY, + disabled = !enabled, + dataAttributes = mapOf( + "identity-action" to "select-organization", + "organization-id" to organization.id.value + ), + modifier = choiceButtonModifier( + selected = selected, + accessibleName = "${if (selected) "Selected: " else "Select "}${organization.name} organization" + ) + ) + } + } +} + +@Composable +fun MembershipManagement( + memberships: List, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Column(modifier = subPanelModifier(modifier).ariaLabel("Membership management")) { + H3(modifier = headingModifier()) { Text("Members") } + if (memberships.isEmpty()) { + P { Text("No memberships are available.") } + return@Column + } + Ul(modifier = listModifier()) { + memberships.forEach { membership -> + Li(modifier = listItemModifier().dataAttribute("membership-id", membership.id.value)) { + Text(membership.displayName, modifier = Modifier().fontWeight(600)) + P { + val address = membership.email?.let { " — $it" }.orEmpty() + Text("${roleLabel(membership.role)}$address; ${membership.state.name.lowercase()}.") + } + if (membership.canChangeRole && membership.state == MembershipState.ACTIVE) { + RoleChoices( + groupLabel = "Role for ${membership.displayName}", + selectedRole = membership.role, + roles = membership.allowedRoles, + enabled = busyAction == null, + actionName = "change-membership-role" + ) { role -> + dispatcher.dispatch( + IdentityUiAction.ChangeMembershipRole(membership.organizationId, membership.id, role) + ) + } + } + if (membership.canRemove) { + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.RemoveMembership(membership.organizationId, membership.id) + ) + }, + label = "Remove member", + variant = ButtonVariant.DANGER, + disabled = busyAction != null || membership.state != MembershipState.ACTIVE, + dataAttributes = mapOf("identity-action" to "remove-membership"), + modifier = actionButtonModifier("Remove ${membership.displayName} from this organization") + ) + } + } + } + } + } +} + +@Composable +fun InvitationManagement( + organizationId: OrganizationId, + draft: InvitationDraftUiState, + invitations: List, + canManage: Boolean, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Column(modifier = subPanelModifier(modifier).ariaLabel("Invitation management")) { + H3(modifier = headingModifier()) { Text("Invitations") } + if (canManage) { + val invitationEmailLabelId = "${IdentityUiIds.INVITATION_EMAIL}-label" + Label( + "Invitee email", + modifier = Modifier().id(invitationEmailLabelId), + forElement = IdentityUiIds.INVITATION_EMAIL + ) + TextField( + value = draft.email, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangeInvitationEmail(it)) }, + label = "Invitee email", + placeholder = "member@example.com", + isEnabled = busyAction == null, + modifier = Modifier() + .id(IdentityUiIds.INVITATION_EMAIL) + .ariaLabel("Invitee email") + .ariaLabelledBy(invitationEmailLabelId) + .ariaInvalid(draft.email.isBlank()) + .responsiveControl() + ) + RoleChoices( + groupLabel = "Invitation role", + selectedRole = draft.role, + roles = draft.allowedRoles, + enabled = busyAction == null, + actionName = "change-invitation-role", + modifier = Modifier().id(IdentityUiIds.INVITATION_ROLE) + ) { role -> dispatcher.dispatch(IdentityUiAction.ChangeInvitationRole(role)) } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.InviteMember(organizationId)) }, + label = if (busyAction == IdentityUiActionKind.INVITE_MEMBER) { + "Sending invitation…" + } else { + "Send invitation" + }, + disabled = busyAction != null || draft.email.isBlank(), + dataAttributes = mapOf("identity-action" to "invite-member"), + modifier = actionButtonModifier( + "Invite ${draft.email.ifBlank { "a member" }} as ${roleLabel(draft.role)}" + ) + ) + } + + if (invitations.isNotEmpty()) { + Ul(modifier = listModifier()) { + invitations.forEach { invitation -> + Li(modifier = listItemModifier().dataAttribute("invitation-id", invitation.id.value)) { + Text(invitation.email, modifier = Modifier().fontWeight(600)) + P { + Text( + "${roleLabel(invitation.role)} invitation; ${invitation.state.name.lowercase()}; " + + "expires ${invitation.expiresAt}." + ) + } + if (invitation.canRevoke && invitation.state == InvitationState.PENDING) { + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.RevokeInvitation(invitation.organizationId, invitation.id) + ) + }, + label = "Revoke invitation", + variant = ButtonVariant.DANGER, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "revoke-invitation"), + modifier = actionButtonModifier("Revoke invitation for ${invitation.email}") + ) + } + } + } + } + } + } +} + +@Composable +fun ServiceIdentityManagement( + organizationId: OrganizationId, + draft: ServiceIdentityDraftUiState, + serviceIdentities: List, + canManage: Boolean, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Column(modifier = subPanelModifier(modifier).ariaLabel("Service identity management")) { + H3(modifier = headingModifier()) { Text("Service identities") } + P { Text("Credentials are organization-bound, scoped, expiring, and shown only when issued.") } + if (canManage) { + val serviceIdentityNameLabelId = "${IdentityUiIds.SERVICE_IDENTITY_NAME}-label" + Label( + "Service identity name", + modifier = Modifier().id(serviceIdentityNameLabelId), + forElement = IdentityUiIds.SERVICE_IDENTITY_NAME + ) + TextField( + value = draft.name, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangeServiceIdentityName(it)) }, + label = "Service identity name", + placeholder = "Release automation", + isEnabled = busyAction == null, + modifier = Modifier() + .id(IdentityUiIds.SERVICE_IDENTITY_NAME) + .ariaLabel("Service identity name") + .ariaLabelledBy(serviceIdentityNameLabelId) + .responsiveControl() + ) + val serviceIdentityDescriptionLabelId = "${IdentityUiIds.SERVICE_IDENTITY_DESCRIPTION}-label" + Label( + "Service identity description", + modifier = Modifier().id(serviceIdentityDescriptionLabelId), + forElement = IdentityUiIds.SERVICE_IDENTITY_DESCRIPTION + ) + TextField( + value = draft.description, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangeServiceIdentityDescription(it)) }, + label = "Service identity description", + placeholder = "What this automation is allowed to do", + isEnabled = busyAction == null, + modifier = Modifier() + .id(IdentityUiIds.SERVICE_IDENTITY_DESCRIPTION) + .ariaLabel("Service identity description") + .ariaLabelledBy(serviceIdentityDescriptionLabelId) + .responsiveControl() + ) + CapabilityChoices( + groupId = IdentityUiIds.SERVICE_IDENTITY_SCOPES, + groupLabel = "Service identity capabilities", + options = draft.capabilityOptions, + selectedCapabilities = draft.selectedCapabilities, + enabled = busyAction == null, + actionName = "toggle-service-identity-capability" + ) { capability, selected -> + dispatcher.dispatch(IdentityUiAction.ToggleServiceIdentityCapability(capability, selected)) + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.CreateServiceIdentity(organizationId)) }, + label = if (busyAction == IdentityUiActionKind.CREATE_SERVICE_IDENTITY) { + "Creating service identity…" + } else { + "Create service identity" + }, + disabled = busyAction != null || draft.name.isBlank() || draft.selectedCapabilities.isEmpty(), + dataAttributes = mapOf("identity-action" to "create-service-identity"), + modifier = actionButtonModifier( + "Create the scoped service identity ${draft.name.ifBlank { "service identity" }}" + ) + ) + } + + if (serviceIdentities.isNotEmpty()) { + Ul(modifier = listModifier()) { + serviceIdentities.forEach { identity -> + Li(modifier = listItemModifier().dataAttribute("service-identity-id", identity.id.value)) { + Text(identity.name, modifier = Modifier().fontWeight(600)) + identity.description?.let { P { Text(it) } } + P { + Text( + "${identity.state.name.lowercase()}; capabilities: " + + capabilitySummary(identity.capabilities) + ) + } + if (identity.canManage && identity.state == ServiceIdentityState.ACTIVE) { + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.CreateServiceCredential(identity.organizationId, identity.id) + ) + }, + label = "Create credential", + variant = ButtonVariant.SECONDARY, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "create-service-credential"), + modifier = actionButtonModifier("Create a credential for ${identity.name}") + ) + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.RevokeServiceIdentity(identity.organizationId, identity.id) + ) + }, + label = "Revoke service identity", + variant = ButtonVariant.DANGER, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "revoke-service-identity"), + modifier = actionButtonModifier("Revoke service identity ${identity.name}") + ) + } + ServiceCredentialList(identity, busyAction, dispatcher) + } + } + } + } + } +} + +@Composable +private fun ServiceCredentialList( + identity: ServiceIdentityUiModel, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher +) { + if (identity.credentials.isEmpty()) return + Ul(modifier = listModifier().ariaLabel("Credentials for ${identity.name}")) { + identity.credentials.forEach { credential -> + Li(modifier = listItemModifier().dataAttribute("service-credential-id", credential.id.value)) { + Text("Credential ${credential.publicPrefix}", modifier = Modifier().fontWeight(600)) + P { + val expiry = credential.expiresAt?.let { "; expires $it" }.orEmpty() + Text( + "${credential.state.name.lowercase()}$expiry; capabilities: " + + capabilitySummary(credential.capabilities) + ) + } + if (identity.canManage && credential.state == ServiceCredentialState.ACTIVE) { + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.RotateServiceCredential(identity.organizationId, credential.id) + ) + }, + label = "Rotate credential", + variant = ButtonVariant.SECONDARY, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "rotate-service-credential"), + modifier = actionButtonModifier("Rotate credential ${credential.publicPrefix}") + ) + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.RevokeServiceCredential(identity.organizationId, credential.id) + ) + }, + label = "Revoke credential", + variant = ButtonVariant.DANGER, + disabled = busyAction != null, + dataAttributes = mapOf("identity-action" to "revoke-service-credential"), + modifier = actionButtonModifier("Revoke credential ${credential.publicPrefix}") + ) + } + } + } + } +} + +@Composable +fun DeviceAuthorizationPanel( + state: DeviceAuthorizationUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section(modifier = panelModifier(modifier).ariaLabel("Device authorization")) { + H3(modifier = headingModifier()) { Text("Connect a device") } + P { Text("Enter the code shown by the CLI. Device codes are never accepted in a URL.") } + val labelId = "${IdentityUiIds.DEVICE_USER_CODE}-label" + Label( + "Device authorization code", + modifier = Modifier().id(labelId), + forElement = IdentityUiIds.DEVICE_USER_CODE + ) + TextField( + value = state.userCode, + onValueChange = { dispatcher.dispatch(IdentityUiAction.ChangeDeviceUserCode(it)) }, + label = "Device authorization code", + placeholder = "ABCD-EFGH", + isEnabled = state.enabled && busyAction == null, + modifier = Modifier() + .id(IdentityUiIds.DEVICE_USER_CODE) + .ariaLabel("Device authorization code") + .ariaLabelledBy(labelId) + .ariaDescribedBy(IdentityUiIds.DEVICE_USER_CODE_HELP) + .responsiveControl() + ) + P(modifier = Modifier().id(IdentityUiIds.DEVICE_USER_CODE_HELP)) { + Text("The code contains eight characters and is formatted automatically.") + } + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.ResolveDeviceAuthorization) }, + label = if (busyAction == IdentityUiActionKind.RESOLVE_DEVICE) "Checking code…" else "Continue", + disabled = !state.enabled || busyAction != null || !state.readyToResolve, + dataAttributes = mapOf("identity-action" to "resolve-device"), + modifier = actionButtonModifier("Continue with this device authorization code") + ) + } +} + +@Composable +fun DeviceApprovalPanel( + state: DeviceApprovalUiState, + busyAction: IdentityUiActionKind?, + dispatcher: IdentityUiDispatcher, + modifier: Modifier = Modifier() +) { + Section( + modifier = panelModifier(modifier) + .id(IdentityUiIds.DEVICE_APPROVAL) + .role("region") + .ariaLabel("Device authorization approval") + .ariaAttribute("live", "polite") + .tabIndex(-1) + .autoFocus() + ) { + H3(modifier = headingModifier()) { Text("Approve a device") } + P { Text("${state.clientName} is requesting access. Confirm that it shows this code:") } + Code(modifier = Modifier().ariaLabel("Device code ${state.userCode}").fontSize("1.25rem")) { + Text(state.userCode) + } + P { Text("This request expires ${state.expiresAt}.") } + P { Text("Choose exactly one organization for this grant. This choice is not a global workspace selection.") } + Column( + modifier = Modifier() + .id(IdentityUiIds.DEVICE_ORGANIZATION) + .role("radiogroup") + .ariaLabel("Organization for device access") + .responsiveContainer() + .gap("0.5rem") + ) { + state.organizations.forEach { organization -> + val selected = organization.id == state.selectedOrganizationId + Button( + onClick = { + dispatcher.dispatch(IdentityUiAction.SelectDeviceOrganization(organization.id)) + }, + label = organization.name, + variant = ButtonVariant.SECONDARY, + disabled = !state.enabled || busyAction != null, + dataAttributes = mapOf( + "identity-action" to "select-device-organization", + "organization-id" to organization.id.value + ), + modifier = choiceButtonModifier( + selected, + "${if (selected) "Selected: " else "Grant device access to "}${organization.name}" + ) + ) + } + } + val approvableCapabilities = state.selectedOrganizationId?.let { + state.approvableCapabilitiesByOrganization.getValue(it) + }.orEmpty() + if (state.selectedOrganizationId == null) { + P(modifier = Modifier().role("status")) { + Text("Choose an organization before selecting device scopes.") + } + } + CapabilityChoices( + groupId = IdentityUiIds.DEVICE_SCOPES, + groupLabel = "Scopes approved for this device", + options = state.capabilityOptions.filter { it.capability in approvableCapabilities }, + selectedCapabilities = state.selectedCapabilities, + enabled = state.enabled && busyAction == null, + actionName = "toggle-device-scope" + ) { capability, selected -> + dispatcher.dispatch(IdentityUiAction.ToggleDeviceCapability(capability, selected)) + } + Button( + onClick = { + dispatcher.dispatch( + IdentityUiAction.ApproveDeviceAuthorization( + userCode = state.userCode, + organizationId = requireNotNull(state.selectedOrganizationId), + capabilities = state.selectedCapabilities + ) + ) + }, + label = if (busyAction == IdentityUiActionKind.APPROVE_DEVICE) "Approving device…" else "Approve device", + disabled = !state.enabled || busyAction != null || state.selectedOrganizationId == null || + state.selectedCapabilities.isEmpty(), + dataAttributes = mapOf("identity-action" to "approve-device"), + modifier = actionButtonModifier("Approve this device for the selected organization and scopes") + ) + Button( + onClick = { dispatcher.dispatch(IdentityUiAction.DenyDeviceAuthorization(state.userCode)) }, + label = if (busyAction == IdentityUiActionKind.DENY_DEVICE) "Denying device…" else "Deny device", + variant = ButtonVariant.DANGER, + disabled = !state.enabled || busyAction != null, + dataAttributes = mapOf("identity-action" to "deny-device"), + modifier = actionButtonModifier("Deny this device authorization request") + ) + } +} + +@Composable +private fun RoleChoices( + groupLabel: String, + selectedRole: OrganizationRole, + roles: Set, + enabled: Boolean, + actionName: String, + modifier: Modifier = Modifier(), + onSelected: (OrganizationRole) -> Unit +) { + Column( + modifier = Modifier() + .role("radiogroup") + .ariaLabel(groupLabel) + .responsiveChoiceGroup() + .then(modifier) + ) { + OrganizationRole.entries.filter { it in roles }.forEach { role -> + Button( + onClick = { onSelected(role) }, + label = roleLabel(role), + variant = ButtonVariant.SECONDARY, + disabled = !enabled, + dataAttributes = mapOf("identity-action" to actionName, "role" to role.wireName), + modifier = choiceButtonModifier(role == selectedRole, "$groupLabel: ${roleLabel(role)}") + ) + } + } +} + +@Composable +private fun CapabilityChoices( + groupId: String, + groupLabel: String, + options: List, + selectedCapabilities: Set, + enabled: Boolean, + actionName: String, + modifier: Modifier = Modifier(), + onSelectionChanged: (Capability, Boolean) -> Unit +) { + Column( + modifier = Modifier() + .id(groupId) + .role("group") + .ariaLabel(groupLabel) + .responsiveContainer() + .gap("0.5rem") + .then(modifier) + ) { + options.forEach { option -> + val descriptionId = "$groupId-${option.capability.wireName.replace('.', '-')}-description" + val selected = option.capability in selectedCapabilities + Button( + onClick = { onSelectionChanged(option.capability, !selected) }, + label = option.label, + variant = ButtonVariant.SECONDARY, + disabled = !enabled, + dataAttributes = mapOf( + "identity-action" to actionName, + "capability" to option.capability.wireName + ), + modifier = actionButtonModifier(option.label) + .role("checkbox") + .ariaChecked(selected) + .ariaDescribedBy(descriptionId) + .dataAttribute("selected", selected.toString()) + ) + option.description?.let { + P(modifier = Modifier().id(descriptionId).margin("0 0 0.25rem 1.75rem")) { Text(it) } + } + } + } +} + +private fun roleLabel(role: OrganizationRole): String = when (role) { + OrganizationRole.OWNER -> "Owner" + OrganizationRole.ADMIN -> "Admin" + OrganizationRole.PUBLISHER -> "Publisher" + OrganizationRole.VIEWER -> "Viewer" +} + +private fun capabilitySummary(capabilities: Set): String = + capabilities.map(Capability::wireName).sorted().joinToString().ifBlank { "none" } + +@Composable +private fun IdentityFeedback(feedback: IdentityUiFeedback?, modifier: Modifier = Modifier()) { + if (feedback == null) return + val isError = feedback.severity == IdentityUiFeedbackSeverity.ERROR + var feedbackModifier = modifier + .id(IdentityUiIds.FEEDBACK) + .role(if (isError) "alert" else "status") + .ariaAttribute("live", if (isError) "assertive" else "polite") + .ariaAttribute("atomic", "true") + .tabIndex(-1) + .padding("0.75rem") + .borderRadius("0.4rem") + .backgroundColor(if (isError) "#fff0f0" else "#eef8ff") + .color(if (isError) "#8b0000" else "#073b5c") + if (isError) feedbackModifier = feedbackModifier.autoFocus() + Text(feedback.message, modifier = feedbackModifier) +} + +/** Mobile-first column which changes to two columns at the shared desktop breakpoint. */ +fun identityResponsiveColumnsModifier(modifier: Modifier = Modifier()): Modifier = modifier + .responsiveContainer() + .display(Display.Flex) + .flexDirection(FlexDirection.Column) + .alignItems(AlignItems.Stretch) + .gap("1rem") + .mediaQuery(MediaQuery.MinWidth(DESKTOP_BREAKPOINT_PX)) { + display(Display.Flex) + .flexDirection(FlexDirection.Row) + .alignItems(AlignItems.FlexStart) + .gap("1.5rem") + } + +private fun identityRootModifier(modifier: Modifier): Modifier = Modifier() + .responsiveContainer() + .maxWidth("72rem") + .margin("0 auto") + .padding("1rem") + .style("overflowWrap", "anywhere") + .mediaQuery(MediaQuery.MinWidth(DESKTOP_BREAKPOINT_PX)) { padding("2rem") } + .then(modifier) + +private fun panelModifier(modifier: Modifier): Modifier = Modifier() + .responsiveContainer() + .padding("1rem") + .border("1px", "solid", "#d7dde5") + .borderRadius("0.75rem") + .backgroundColor("#ffffff") + .then(modifier) + +private fun headingModifier(): Modifier = Modifier().margin("0 0 0.75rem 0") + +private fun listModifier(): Modifier = Modifier().padding("0").margin("0").dataAttribute("identity-list", "true") + +private fun listItemModifier(modifier: Modifier = Modifier()): Modifier = Modifier() + .responsiveContainer() + .padding("0.75rem 0") + .borderWidth(0) + .borderBottomWidth(1) + .borderStyle(BorderStyle.Solid) + .borderColor("#e5e9ef") + .then(modifier) + +private fun actionButtonModifier(accessibleName: String): Modifier = Modifier() + .ariaLabel(accessibleName) + .tabIndex(0) + .fontSize("1rem") + // Summon buttons have a fixed 0.25rem inline margin, so reserve that space at narrow widths. + .maxWidth("calc(100% - 0.5rem)") + .minWidth("0") + .style("boxSizing", "border-box") + .style("whiteSpace", "normal") + +private fun choiceButtonModifier(selected: Boolean, accessibleName: String): Modifier = + actionButtonModifier(accessibleName) + .role("radio") + .ariaChecked(selected) + .dataAttribute("selected", selected.toString()) + +private fun subPanelModifier(modifier: Modifier): Modifier = Modifier() + .responsiveContainer() + .padding("1rem 0 0") + .borderWidth(0) + .borderTopWidth(1) + .borderStyle(BorderStyle.Solid) + .borderColor("#e5e9ef") + .then(modifier) + +private fun Modifier.responsiveContainer(): Modifier = this + .fillMaxWidth() + .minWidth("0") + .style("boxSizing", "border-box") + +private fun Modifier.responsiveControl(): Modifier = this + .responsiveContainer() + .maxWidth("100%") + +private fun Modifier.responsiveChoiceGroup(): Modifier = this + .responsiveContainer() + .display(Display.Flex) + .flexDirection(FlexDirection.Column) + .gap("0.5rem") + .mediaQuery(MediaQuery.MinWidth(DESKTOP_BREAKPOINT_PX)) { + flexDirection(FlexDirection.Row) + .style("flexWrap", "wrap") + } diff --git a/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityUiActions.kt b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityUiActions.kt new file mode 100644 index 0000000..e2c99fa --- /dev/null +++ b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityUiActions.kt @@ -0,0 +1,201 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.CredentialId +import codes.yousef.aether.auth.InvitationId +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.ServiceCredentialId +import codes.yousef.aether.auth.ServiceIdentityId +import codes.yousef.aether.auth.SessionId + +sealed interface IdentityUiAction { + data class ChangeRegistrationName(val value: String) : IdentityUiAction + data object RegisterPasskey : IdentityUiAction + data object DiscoverableSignIn : IdentityUiAction + data class ChangePasskeyName(val credentialId: CredentialId, val value: String) : IdentityUiAction + data class RenamePasskey(val credentialId: CredentialId) : IdentityUiAction + data class RevokePasskey(val credentialId: CredentialId) : IdentityUiAction + data class RevokeSession(val sessionId: SessionId) : IdentityUiAction + data object RevokeOtherSessions : IdentityUiAction + data object RevokeAllSessions : IdentityUiAction + data object GenerateRecoveryCodes : IdentityUiAction + data object DismissRecoveryCodes : IdentityUiAction + data object DismissOneTimeSecret : IdentityUiAction + data class ChangeAdministrativeRecoveryUser(val value: String) : IdentityUiAction + data object IssueAdministrativeRecovery : IdentityUiAction + data class CancelAdministrativeRecovery(val ticketId: ChallengeId) : IdentityUiAction + data object StepUpWithPasskey : IdentityUiAction + data class SelectOrganization(val organizationId: OrganizationId) : IdentityUiAction + data class ChangeMembershipRole( + val organizationId: OrganizationId, + val membershipId: MembershipId, + val role: OrganizationRole + ) : IdentityUiAction + data class RemoveMembership(val organizationId: OrganizationId, val membershipId: MembershipId) : IdentityUiAction + data class ChangeInvitationEmail(val value: String) : IdentityUiAction + data class ChangeInvitationRole(val role: OrganizationRole) : IdentityUiAction + data class InviteMember(val organizationId: OrganizationId) : IdentityUiAction + data class RevokeInvitation(val organizationId: OrganizationId, val invitationId: InvitationId) : IdentityUiAction + data class ChangeServiceIdentityName(val value: String) : IdentityUiAction + data class ChangeServiceIdentityDescription(val value: String) : IdentityUiAction + data class ToggleServiceIdentityCapability(val capability: Capability, val selected: Boolean) : IdentityUiAction + data class CreateServiceIdentity(val organizationId: OrganizationId) : IdentityUiAction + data class CreateServiceCredential( + val organizationId: OrganizationId, + val serviceIdentityId: ServiceIdentityId + ) : IdentityUiAction + data class RotateServiceCredential( + val organizationId: OrganizationId, + val credentialId: ServiceCredentialId + ) : IdentityUiAction + data class RevokeServiceCredential( + val organizationId: OrganizationId, + val credentialId: ServiceCredentialId + ) : IdentityUiAction + data class RevokeServiceIdentity( + val organizationId: OrganizationId, + val serviceIdentityId: ServiceIdentityId + ) : IdentityUiAction + data class ChangeDeviceUserCode(val value: String) : IdentityUiAction { + override fun toString(): String = "ChangeDeviceUserCode(value=)" + } + data object ResolveDeviceAuthorization : IdentityUiAction + data class SelectDeviceOrganization(val organizationId: OrganizationId) : IdentityUiAction + data class ToggleDeviceCapability(val capability: Capability, val selected: Boolean) : IdentityUiAction + data class ApproveDeviceAuthorization( + val userCode: String, + val organizationId: OrganizationId, + val capabilities: Set + ) : IdentityUiAction { + override fun toString(): String = + "ApproveDeviceAuthorization(userCode=, organizationId=$organizationId, capabilities=$capabilities)" + } + data class DenyDeviceAuthorization(val userCode: String) : IdentityUiAction { + override fun toString(): String = "DenyDeviceAuthorization(userCode=)" + } + data object ClearFeedback : IdentityUiAction +} + +fun interface IdentityUiDispatcher { + fun dispatch(action: IdentityUiAction) +} + +/** Applies the local, synchronous edits. Network-backed actions remain the host's responsibility. */ +fun reduceIdentityUiState(state: IdentityUiState, action: IdentityUiAction): IdentityUiState = when (action) { + is IdentityUiAction.ChangeRegistrationName -> state.copy( + registration = state.registration.copy(passkeyName = action.value.take(200)) + ) + + is IdentityUiAction.ChangePasskeyName -> state.copy( + passkeys = state.passkeys.map { passkey -> + if (passkey.id == action.credentialId) passkey.copy(renameDraft = action.value.take(200)) else passkey + } + ) + + IdentityUiAction.DismissRecoveryCodes -> state.copy(recoveryCodes = RecoveryCodesUiState.Hidden) + IdentityUiAction.DismissOneTimeSecret -> state.copy(oneTimeSecret = OneTimeIdentitySecretUiState.Hidden) + is IdentityUiAction.ChangeAdministrativeRecoveryUser -> state.copy( + administrativeRecovery = state.administrativeRecovery.copy(userQuery = action.value.take(320)) + ) + + is IdentityUiAction.SelectOrganization -> if ( + state.organizationManagement.organizations.any { it.id == action.organizationId } + ) { + state.copy( + organizationManagement = state.organizationManagement.copy( + selectedOrganizationId = action.organizationId, + memberships = emptyList(), + invitations = emptyList(), + serviceIdentities = emptyList(), + invitationDraft = state.organizationManagement.invitationDraft.copy(email = ""), + serviceIdentityDraft = ServiceIdentityDraftUiState() + ) + ) + } else { + state + } + + is IdentityUiAction.ChangeInvitationEmail -> state.copy( + organizationManagement = state.organizationManagement.copy( + invitationDraft = state.organizationManagement.invitationDraft.copy(email = action.value.take(320)) + ) + ) + + is IdentityUiAction.ChangeInvitationRole -> { + val draft = state.organizationManagement.invitationDraft + if (action.role !in draft.allowedRoles) state else state.copy( + organizationManagement = state.organizationManagement.copy( + invitationDraft = draft.copy(role = action.role) + ) + ) + } + + is IdentityUiAction.ChangeServiceIdentityName -> state.copy( + organizationManagement = state.organizationManagement.copy( + serviceIdentityDraft = state.organizationManagement.serviceIdentityDraft.copy( + name = action.value.take(200) + ) + ) + ) + + is IdentityUiAction.ChangeServiceIdentityDescription -> state.copy( + organizationManagement = state.organizationManagement.copy( + serviceIdentityDraft = state.organizationManagement.serviceIdentityDraft.copy( + description = action.value.take(2_000) + ) + ) + ) + + is IdentityUiAction.ChangeDeviceUserCode -> state.copy( + deviceAuthorization = state.deviceAuthorization.copy( + userCode = normalizeDeviceUserCodeDraft(action.value) + ) + ) + + is IdentityUiAction.ToggleServiceIdentityCapability -> { + val draft = state.organizationManagement.serviceIdentityDraft + if (draft.capabilityOptions.none { it.capability == action.capability }) state else { + val selected = draft.selectedCapabilities.toggle(action.capability, action.selected) + state.copy( + organizationManagement = state.organizationManagement.copy( + serviceIdentityDraft = draft.copy(selectedCapabilities = selected) + ) + ) + } + } + + is IdentityUiAction.SelectDeviceOrganization -> { + val approval = state.deviceApproval + if (approval == null || approval.organizations.none { it.id == action.organizationId }) state else { + state.copy( + deviceApproval = approval.copy( + selectedOrganizationId = action.organizationId, + selectedCapabilities = emptySet() + ) + ) + } + } + + is IdentityUiAction.ToggleDeviceCapability -> { + val approval = state.deviceApproval + val organizationId = approval?.selectedOrganizationId + if (approval == null || organizationId == null || + action.capability !in approval.approvableCapabilitiesByOrganization.getValue(organizationId) + ) state else { + state.copy( + deviceApproval = approval.copy( + selectedCapabilities = approval.selectedCapabilities.toggle(action.capability, action.selected) + ) + ) + } + } + + IdentityUiAction.ClearFeedback -> state.copy(feedback = null) + else -> state +} + +private fun Set.toggle(capability: Capability, selected: Boolean): Set = + if (selected) this + capability else this - capability diff --git a/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityUiModels.kt b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityUiModels.kt new file mode 100644 index 0000000..7364bf1 --- /dev/null +++ b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/IdentityUiModels.kt @@ -0,0 +1,477 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.CredentialId +import codes.yousef.aether.auth.InvitationId +import codes.yousef.aether.auth.InvitationState +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.MembershipState +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.ServiceCredentialId +import codes.yousef.aether.auth.ServiceCredentialState +import codes.yousef.aether.auth.ServiceIdentityId +import codes.yousef.aether.auth.ServiceIdentityState +import codes.yousef.aether.auth.SessionId +import codes.yousef.aether.auth.UserId + +/** + * Browser-safe state consumed by the identity components. + * + * Deliberately do not add authority configuration, session tokens, token digests, secret + * references, or an [codes.yousef.aether.auth.IdentityRuntime] to this graph. The sole secret + * exceptions are explicitly one-time display values and the short-lived RFC 8628 human code being + * entered or approved. Hosts must never persist them. JVM callers map their server models to these + * summaries before rendering. + */ +data class IdentityUiState( + val signedInDisplayName: String? = null, + val registration: RegistrationUiState = RegistrationUiState(), + val passkeys: List = emptyList(), + val sessions: List = emptyList(), + val recoveryCodes: RecoveryCodesUiState = RecoveryCodesUiState.Hidden, + val oneTimeSecret: OneTimeIdentitySecretUiState = OneTimeIdentitySecretUiState.Hidden, + val administrativeRecovery: AdministrativeRecoveryUiState = AdministrativeRecoveryUiState(), + val stepUp: StepUpUiState = StepUpUiState(), + val organizationManagement: OrganizationManagementUiState = OrganizationManagementUiState(), + val deviceAuthorization: DeviceAuthorizationUiState = DeviceAuthorizationUiState(), + val deviceApproval: DeviceApprovalUiState? = null, + val feedback: IdentityUiFeedback? = null, + val busyAction: IdentityUiActionKind? = null +) { + init { + require(passkeys.map { it.id }.distinct().size == passkeys.size) { "Passkey IDs must be unique" } + require(sessions.map { it.id }.distinct().size == sessions.size) { "Session IDs must be unique" } + } +} + +/** Browser-safe organization state. Route handlers must populate it from authorized service views. */ +data class OrganizationManagementUiState( + val organizations: List = emptyList(), + val selectedOrganizationId: OrganizationId? = null, + val memberships: List = emptyList(), + val invitationDraft: InvitationDraftUiState = InvitationDraftUiState(), + val invitations: List = emptyList(), + val serviceIdentityDraft: ServiceIdentityDraftUiState = ServiceIdentityDraftUiState(), + val serviceIdentities: List = emptyList(), + val canInviteMembers: Boolean = false, + val canManageServiceIdentities: Boolean = false +) { + init { + require(organizations.map { it.id }.distinct().size == organizations.size) { + "Organization IDs must be unique" + } + require(selectedOrganizationId == null || organizations.any { it.id == selectedOrganizationId }) { + "The selected organization must be present in the organization list" + } + require(memberships.map { it.id }.distinct().size == memberships.size) { + "Membership IDs must be unique" + } + require(invitations.map { it.id }.distinct().size == invitations.size) { + "Invitation IDs must be unique" + } + require(serviceIdentities.map { it.id }.distinct().size == serviceIdentities.size) { + "Service identity IDs must be unique" + } + if (selectedOrganizationId == null) { + require(memberships.isEmpty() && invitations.isEmpty() && serviceIdentities.isEmpty()) { + "Organization resources require an explicit selected organization" + } + } else { + require(memberships.all { it.organizationId == selectedOrganizationId }) { + "Memberships must belong to the selected organization" + } + require(invitations.all { it.organizationId == selectedOrganizationId }) { + "Invitations must belong to the selected organization" + } + require(serviceIdentities.all { it.organizationId == selectedOrganizationId }) { + "Service identities must belong to the selected organization" + } + } + } +} + +data class OrganizationUiModel( + val id: OrganizationId, + val name: String, + val slug: String, + val role: OrganizationRole +) { + init { + require(name.isNotBlank() && name.length <= MAX_NAME_LENGTH) { "Invalid organization name" } + require(ORGANIZATION_SLUG_PATTERN.matches(slug)) { "Invalid organization slug" } + } +} + +data class MembershipUiModel( + val id: MembershipId, + val organizationId: OrganizationId, + val userId: UserId, + val displayName: String, + val email: String? = null, + val role: OrganizationRole, + val state: MembershipState = MembershipState.ACTIVE, + val allowedRoles: Set = OrganizationRole.entries.toSet(), + val canChangeRole: Boolean = false, + val canRemove: Boolean = false +) { + init { + require(displayName.isNotBlank() && displayName.length <= MAX_NAME_LENGTH) { "Invalid member name" } + require(email == null || email.length <= MAX_USER_QUERY_LENGTH) { "Member email is too long" } + require(allowedRoles.isNotEmpty() && role in allowedRoles) { "Membership role must be an allowed role" } + } +} + +data class InvitationDraftUiState( + val email: String = "", + val role: OrganizationRole = OrganizationRole.VIEWER, + val allowedRoles: Set = OrganizationRole.entries.toSet() +) { + init { + require(email.length <= MAX_USER_QUERY_LENGTH) { "Invitation email is too long" } + require(allowedRoles.isNotEmpty() && role in allowedRoles) { "Invitation role must be allowed" } + } +} + +data class InvitationUiModel( + val id: InvitationId, + val organizationId: OrganizationId, + val email: String, + val role: OrganizationRole, + val state: InvitationState, + val expiresAt: String, + val canRevoke: Boolean = state == InvitationState.PENDING +) { + init { + require(email.isNotBlank() && email.length <= MAX_USER_QUERY_LENGTH) { "Invalid invitation email" } + require(expiresAt.isNotBlank() && expiresAt.length <= MAX_TIMESTAMP_LENGTH) { "Invalid invitation expiry" } + } +} + +/** A public, allowlisted capability choice. It never carries credential or authority material. */ +data class CapabilityOptionUiModel( + val capability: Capability, + val label: String, + val description: String? = null +) { + init { + require(label.isNotBlank() && label.length <= MAX_NAME_LENGTH) { "Invalid capability label" } + require(description == null || description.length <= MAX_DESCRIPTION_LENGTH) { + "Capability description is too long" + } + } +} + +data class ServiceIdentityDraftUiState( + val name: String = "", + val description: String = "", + val capabilityOptions: List = emptyList(), + val selectedCapabilities: Set = emptySet() +) { + init { + require(name.length <= MAX_NAME_LENGTH) { "Service identity name is too long" } + require(description.length <= MAX_DESCRIPTION_LENGTH) { "Service identity description is too long" } + requireUniqueCapabilityOptions(capabilityOptions) + require(capabilityOptions.map { it.capability }.containsAll(selectedCapabilities)) { + "Selected service capabilities must be allowlisted options" + } + } +} + +data class ServiceIdentityUiModel( + val id: ServiceIdentityId, + val organizationId: OrganizationId, + val name: String, + val description: String? = null, + val capabilities: Set, + val state: ServiceIdentityState, + val credentials: List = emptyList(), + val canManage: Boolean = false +) { + init { + require(name.isNotBlank() && name.length <= MAX_NAME_LENGTH) { "Invalid service identity name" } + require(description == null || description.length <= MAX_DESCRIPTION_LENGTH) { + "Service identity description is too long" + } + require(credentials.map { it.id }.distinct().size == credentials.size) { + "Service credential IDs must be unique" + } + } +} + +data class ServiceCredentialUiModel( + val id: ServiceCredentialId, + val publicPrefix: String, + val capabilities: Set, + val state: ServiceCredentialState, + val expiresAt: String? = null +) { + init { + require(SERVICE_CREDENTIAL_PREFIX_PATTERN.matches(publicPrefix)) { + "Invalid service credential prefix" + } + require(expiresAt == null || expiresAt.length <= MAX_TIMESTAMP_LENGTH) { + "Service credential expiry is too long" + } + } +} + +/** + * Signed-in manual entry for an RFC 8628 human code. The draft is never read from or written to a + * URL, browser storage, or diagnostics. + */ +data class DeviceAuthorizationUiState( + val userCode: String = "", + val enabled: Boolean = true +) { + init { + require(userCode.length <= USER_CODE_WIRE_LENGTH) { "Device user code draft is too long" } + require(userCode.all { it == '-' || it in USER_CODE_ALPHABET }) { "Invalid device user code draft" } + } + + val readyToResolve: Boolean get() = USER_CODE_PATTERN.matches(userCode) + + override fun toString(): String = + "DeviceAuthorizationUiState(userCode=, enabled=$enabled, readyToResolve=$readyToResolve)" +} + +/** + * Pending RFC 8628 approval data. The device code and every resulting token stay outside UI state. + * The organization is deliberately selected here, independently from the management panel. + */ +data class DeviceApprovalUiState( + val userCode: String, + val clientName: String, + val expiresAt: String, + val organizations: List, + val approvableCapabilitiesByOrganization: Map>, + val selectedOrganizationId: OrganizationId? = null, + val capabilityOptions: List, + val selectedCapabilities: Set = emptySet(), + val enabled: Boolean = true +) { + init { + require(USER_CODE_PATTERN.matches(userCode)) { "Invalid device user code" } + require(clientName.isNotBlank() && clientName.length <= MAX_NAME_LENGTH) { "Invalid device client name" } + require(expiresAt.isNotBlank() && expiresAt.length <= MAX_TIMESTAMP_LENGTH) { "Invalid device grant expiry" } + require(organizations.isNotEmpty()) { "Device approval requires at least one organization option" } + require(organizations.map { it.id }.distinct().size == organizations.size) { + "Device organization IDs must be unique" + } + require(approvableCapabilitiesByOrganization.keys == organizations.mapTo(linkedSetOf()) { it.id }) { + "Every device organization requires an explicit capability grant set" + } + require(selectedOrganizationId == null || organizations.any { it.id == selectedOrganizationId }) { + "The device organization selection must be one of the offered organizations" + } + requireUniqueCapabilityOptions(capabilityOptions) + require(capabilityOptions.isNotEmpty()) { "Device approval requires at least one requested scope" } + val requested = capabilityOptions.mapTo(linkedSetOf()) { it.capability } + require(approvableCapabilitiesByOrganization.values.all(requested::containsAll)) { + "Organization device grants must be subsets of the requested scopes" + } + require(approvableCapabilitiesByOrganization.values.all { it.isNotEmpty() }) { + "Every offered device organization must allow at least one requested scope" + } + require(selectedOrganizationId != null || selectedCapabilities.isEmpty()) { + "Device scopes cannot be selected before an organization" + } + require(capabilityOptions.map { it.capability }.containsAll(selectedCapabilities)) { + "Selected device scopes must be requested capability options" + } + require( + selectedOrganizationId == null || + approvableCapabilitiesByOrganization.getValue(selectedOrganizationId).containsAll(selectedCapabilities) + ) { + "Selected device scopes must be allowed for the selected organization" + } + } + + override fun toString(): String = + "DeviceApprovalUiState(userCode=, clientName=$clientName, expiresAt=$expiresAt, " + + "organizations=$organizations, selectedOrganizationId=$selectedOrganizationId, " + + "capabilityOptions=$capabilityOptions, selectedCapabilities=$selectedCapabilities, enabled=$enabled)" +} + +data class RegistrationUiState( + val passkeyName: String = "", + val enabled: Boolean = true, + /** False only for constrained bootstrap/recovery enrollment sessions. */ + val signInEnabled: Boolean = true +) { + init { + require(passkeyName.length <= MAX_NAME_LENGTH) { "Passkey name is too long" } + } +} + +data class PasskeyUiModel( + val id: CredentialId, + val name: String, + val renameDraft: String = name, + val createdAt: String, + val lastUsedAt: String? = null, + val backedUp: Boolean = false, + val canRevoke: Boolean = true +) { + init { + require(name.isNotBlank() && name.length <= MAX_NAME_LENGTH) { "Invalid passkey name" } + require(renameDraft.length <= MAX_NAME_LENGTH) { "Passkey rename draft is too long" } + } +} + +data class SessionUiModel( + val id: SessionId, + val deviceLabel: String, + val lastUsedAt: String, + val expiresAt: String, + val current: Boolean = false, + val recentPasskey: Boolean = false +) { + init { + require(deviceLabel.isNotBlank() && deviceLabel.length <= MAX_DEVICE_LABEL_LENGTH) { + "Invalid device label" + } + } +} + +sealed interface RecoveryCodesUiState { + data object Hidden : RecoveryCodesUiState + + /** The ten newly generated codes. The values are intentionally shown only in this state. */ + class VisibleOnce(codes: List) : RecoveryCodesUiState { + val codes: List = codes.toList() + + init { + require(this.codes.size == 10) { "Exactly ten recovery codes must be displayed" } + require(this.codes.all { it.isNotBlank() && it.length <= MAX_RECOVERY_CODE_LENGTH }) { + "Invalid recovery code display value" + } + } + + override fun toString(): String = "VisibleOnce(codes=[REDACTED])" + } +} + +enum class OneTimeIdentitySecretKind { + INVITATION_TOKEN, + SERVICE_CREDENTIAL +} + +/** + * An issued invitation or service-credential token rendered exactly until the user dismisses it. + * Hosts must construct this only from a mutation response and must never persist or refetch it. + */ +sealed interface OneTimeIdentitySecretUiState { + data object Hidden : OneTimeIdentitySecretUiState + + class VisibleOnce( + val kind: OneTimeIdentitySecretKind, + val label: String, + secret: String + ) : OneTimeIdentitySecretUiState { + val secret: String = secret + + init { + require(label.isNotBlank() && label.length <= MAX_NAME_LENGTH) { "Invalid one-time secret label" } + require(secret.length in 20..1_024 && secret.none(Char::isWhitespace)) { + "Invalid one-time identity secret" + } + } + + override fun toString(): String = "VisibleOnce(kind=$kind, label=$label, secret=)" + } +} + +data class AdministrativeRecoveryUiState( + val enabled: Boolean = false, + val userQuery: String = "", + val outstandingTicket: AdministrativeRecoveryTicketUiModel? = null, + val deliveryStatus: String? = null +) { + init { + require(userQuery.length <= MAX_USER_QUERY_LENGTH) { "Recovery user query is too long" } + } +} + +/** Contains only a public audit/reference ID. The enrollment ticket secret is never UI state. */ +data class AdministrativeRecoveryTicketUiModel( + val id: ChallengeId, + val userId: UserId, + val expiresAt: String +) + +data class StepUpUiState( + val required: Boolean = false, + val satisfiedAt: String? = null, + val reason: String? = null +) + +data class IdentityUiFeedback( + val message: String, + val severity: IdentityUiFeedbackSeverity = IdentityUiFeedbackSeverity.STATUS +) { + init { + require(message.isNotBlank() && message.length <= MAX_FEEDBACK_LENGTH) { "Invalid feedback message" } + } +} + +enum class IdentityUiFeedbackSeverity { STATUS, ERROR } + +enum class IdentityUiActionKind { + REGISTER_PASSKEY, + SIGN_IN, + RENAME_PASSKEY, + REVOKE_PASSKEY, + REVOKE_SESSION, + REVOKE_OTHER_SESSIONS, + REVOKE_ALL_SESSIONS, + GENERATE_RECOVERY_CODES, + ADMINISTRATIVE_RECOVERY, + STEP_UP, + SELECT_ORGANIZATION, + UPDATE_MEMBERSHIP, + REMOVE_MEMBERSHIP, + INVITE_MEMBER, + REVOKE_INVITATION, + CREATE_SERVICE_IDENTITY, + CREATE_SERVICE_CREDENTIAL, + ROTATE_SERVICE_CREDENTIAL, + REVOKE_SERVICE_CREDENTIAL, + REVOKE_SERVICE_IDENTITY, + RESOLVE_DEVICE, + APPROVE_DEVICE, + DENY_DEVICE +} + +enum class IdentityLayoutClass { PHONE, DESKTOP } + +fun identityLayoutClass(viewportWidthPx: Int): IdentityLayoutClass { + require(viewportWidthPx >= 0) { "Viewport width must not be negative" } + return if (viewportWidthPx < DESKTOP_BREAKPOINT_PX) IdentityLayoutClass.PHONE else IdentityLayoutClass.DESKTOP +} + +internal const val DESKTOP_BREAKPOINT_PX = 768 +private const val MAX_NAME_LENGTH = 200 +private const val MAX_DEVICE_LABEL_LENGTH = 200 +private const val MAX_USER_QUERY_LENGTH = 320 +private const val MAX_FEEDBACK_LENGTH = 1_000 +private const val MAX_RECOVERY_CODE_LENGTH = 200 +private const val MAX_DESCRIPTION_LENGTH = 2_000 +private const val MAX_TIMESTAMP_LENGTH = 100 +private const val USER_CODE_WIRE_LENGTH = 9 +private const val USER_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" +private val ORGANIZATION_SLUG_PATTERN = Regex("[a-z0-9][a-z0-9-]{1,62}") +private val SERVICE_CREDENTIAL_PREFIX_PATTERN = Regex("[A-Za-z0-9_-]{6,64}") +private val USER_CODE_PATTERN = Regex("[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}") + +internal fun normalizeDeviceUserCodeDraft(value: String): String { + val symbols = value.take(64).uppercase().filter { it in USER_CODE_ALPHABET }.take(8) + return if (symbols.length <= 4) symbols else "${symbols.take(4)}-${symbols.drop(4)}" +} + +private fun requireUniqueCapabilityOptions(options: List) { + require(options.map { it.capability }.distinct().size == options.size) { + "Capability options must be unique" + } +} diff --git a/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/PasskeyCeremonyClient.kt b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/PasskeyCeremonyClient.kt new file mode 100644 index 0000000..05ffb4f --- /dev/null +++ b/aether-auth-summon/src/commonMain/kotlin/codes/yousef/aether/auth/summon/PasskeyCeremonyClient.kt @@ -0,0 +1,70 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.webauthn.AuthenticationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialCreationOptions +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialRequestOptions +import codes.yousef.aether.auth.webauthn.RegistrationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.WebAuthnAuthenticationStartResponse +import codes.yousef.aether.auth.webauthn.WebAuthnRegistrationStartResponse + +/** + * Narrow browser authority. Implementations may invoke `navigator.credentials`, but can receive + * only public ceremony options and return only browser credential envelopes. + */ +interface PasskeyBrowserClient { + suspend fun create(options: PublicKeyCredentialCreationOptions): RegistrationPublicKeyCredentialDto + suspend fun get(options: PublicKeyCredentialRequestOptions): AuthenticationPublicKeyCredentialDto +} + +enum class PasskeyAuthenticationPurpose { DISCOVERABLE_SIGN_IN, STEP_UP } + +/** Server JSON API boundary. It never returns a cookie, token, secret reference, or key material. */ +interface PasskeyCeremonyGateway { + suspend fun startRegistration(passkeyName: String): WebAuthnRegistrationStartResponse + + suspend fun finishRegistration( + ceremonyId: ChallengeId, + credential: RegistrationPublicKeyCredentialDto + ) + + suspend fun startAuthentication(purpose: PasskeyAuthenticationPurpose): WebAuthnAuthenticationStartResponse + + suspend fun finishAuthentication( + ceremonyId: ChallengeId, + purpose: PasskeyAuthenticationPurpose, + credential: AuthenticationPublicKeyCredentialDto + ) +} + +/** Coordinates the public start/browser/finish exchange without retaining ceremony material. */ +class PasskeyCeremonyClient( + private val gateway: PasskeyCeremonyGateway, + private val browser: PasskeyBrowserClient +) { + suspend fun register(passkeyName: String) { + require(passkeyName.isNotBlank() && passkeyName.length <= 200) { "Invalid passkey name" } + val start = gateway.startRegistration(passkeyName) + val credential = browser.create(start.publicKey) + gateway.finishRegistration(start.ceremonyId, credential) + } + + suspend fun authenticate(purpose: PasskeyAuthenticationPurpose) { + val start = gateway.startAuthentication(purpose) + val credential = browser.get(start.publicKey) + gateway.finishAuthentication(start.ceremonyId, purpose, credential) + } +} + +enum class PasskeyBrowserErrorCode { + NOT_SUPPORTED, + NOT_ALLOWED, + ABORTED, + SECURITY_ERROR, + INVALID_RESPONSE, + UNKNOWN +} + +class PasskeyBrowserException(val code: PasskeyBrowserErrorCode) : IllegalStateException( + "Passkey browser ceremony failed" +) diff --git a/aether-auth-summon/src/commonTest/kotlin/codes/yousef/aether/auth/summon/IdentityUiStateTest.kt b/aether-auth-summon/src/commonTest/kotlin/codes/yousef/aether/auth/summon/IdentityUiStateTest.kt new file mode 100644 index 0000000..ef518e9 --- /dev/null +++ b/aether-auth-summon/src/commonTest/kotlin/codes/yousef/aether/auth/summon/IdentityUiStateTest.kt @@ -0,0 +1,259 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.CredentialId +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.UserId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class IdentityUiStateTest { + @Test + fun layoutUsesMobileFirstBreakpoint() { + assertEquals(IdentityLayoutClass.PHONE, identityLayoutClass(320)) + assertEquals(IdentityLayoutClass.PHONE, identityLayoutClass(767)) + assertEquals(IdentityLayoutClass.DESKTOP, identityLayoutClass(768)) + assertEquals(IdentityLayoutClass.DESKTOP, identityLayoutClass(1_440)) + + val responsiveStyles = identityResponsiveColumnsModifier().styles + assertEquals("100%", responsiveStyles["width"]) + assertEquals("0", responsiveStyles["min-width"]) + assertEquals("border-box", responsiveStyles["boxSizing"]) + } + + @Test + fun reducerChangesOnlyBrowserSafeDraftState() { + val credentialId = CredentialId("018f-ui-credential") + val initial = IdentityUiState( + registration = RegistrationUiState("Old key"), + passkeys = listOf( + PasskeyUiModel( + id = credentialId, + name = "Laptop", + createdAt = "2026-07-14T08:00:00Z" + ) + ), + recoveryCodes = visibleRecoveryCodes() + ) + + val registrationChanged = reduceIdentityUiState( + initial, + IdentityUiAction.ChangeRegistrationName("Security key") + ) + val passkeyChanged = reduceIdentityUiState( + registrationChanged, + IdentityUiAction.ChangePasskeyName(credentialId, "Travel key") + ) + val hidden = reduceIdentityUiState(passkeyChanged, IdentityUiAction.DismissRecoveryCodes) + + assertEquals("Security key", hidden.registration.passkeyName) + assertEquals("Travel key", hidden.passkeys.single().renameDraft) + assertIs(hidden.recoveryCodes) + } + + @Test + fun recoveryCodeStateRequiresTenValuesAndRedactsDiagnostics() { + val state = visibleRecoveryCodes() + + assertEquals(10, state.codes.size) + assertEquals("VisibleOnce(codes=[REDACTED])", state.toString()) + assertFalse(state.toString().contains("recovery-code-1")) + assertTrue(state.codes.first().startsWith("recovery-code-")) + } + + @Test + fun oneTimeIdentitySecretIsRedactedAndDismissedLocally() { + val raw = "svc_123456.abcdefghijklmnopqrstuvwxyz012345" + val visible = OneTimeIdentitySecretUiState.VisibleOnce( + OneTimeIdentitySecretKind.SERVICE_CREDENTIAL, + "Release credential", + raw + ) + val initial = IdentityUiState(oneTimeSecret = visible) + + assertFalse(visible.toString().contains(raw)) + assertFalse(initial.toString().contains(raw)) + assertEquals(raw, visible.secret) + assertIs( + reduceIdentityUiState(initial, IdentityUiAction.DismissOneTimeSecret).oneTimeSecret + ) + } + + @Test + fun deviceUserCodeDraftIsFormattedBoundedAndRedacted() { + val initial = IdentityUiState(signedInDisplayName = "Owner") + val action = IdentityUiAction.ChangeDeviceUserCode("abcd-efgh-untrusted-tail") + + val changed = reduceIdentityUiState(initial, action) + + assertEquals("ABCD-EFGH", changed.deviceAuthorization.userCode) + assertTrue(changed.deviceAuthorization.readyToResolve) + assertFalse(action.toString().contains("abcd-efgh")) + assertFalse(changed.deviceAuthorization.toString().contains("ABCD-EFGH")) + assertFalse(changed.toString().contains("ABCD-EFGH")) + } + + @Test + fun organizationAndDeviceDraftReducersAcceptOnlyOfferedChoices() { + val first = OrganizationUiModel( + OrganizationId("org-first"), + "First organization", + "first-org", + OrganizationRole.OWNER + ) + val second = OrganizationUiModel( + OrganizationId("org-second"), + "Second organization", + "second-org", + OrganizationRole.ADMIN + ) + val read = Capability.CONTENT_READ + val publish = Capability.CONTENT_PUBLISH + val initial = IdentityUiState( + organizationManagement = OrganizationManagementUiState( + organizations = listOf(first, second), + selectedOrganizationId = first.id, + memberships = listOf( + MembershipUiModel( + id = MembershipId("membership-first"), + organizationId = first.id, + userId = UserId("user-first"), + displayName = "Member", + role = OrganizationRole.VIEWER + ) + ), + serviceIdentityDraft = ServiceIdentityDraftUiState( + capabilityOptions = listOf(CapabilityOptionUiModel(read, "Read content")) + ) + ), + deviceApproval = DeviceApprovalUiState( + userCode = "ABCD-EFGH", + clientName = "Aether CLI", + expiresAt = "2026-07-14T10:00:00Z", + organizations = listOf(first, second), + approvableCapabilitiesByOrganization = mapOf( + first.id to setOf(read), + second.id to setOf(publish) + ), + capabilityOptions = listOf( + CapabilityOptionUiModel(read, "Read content"), + CapabilityOptionUiModel(publish, "Publish content") + ) + ) + ) + assertFalse(initial.deviceApproval.toString().contains("ABCD-EFGH")) + assertFalse( + IdentityUiAction.ApproveDeviceAuthorization("ABCD-EFGH", first.id, setOf(read)) + .toString() + .contains("ABCD-EFGH") + ) + assertFalse(IdentityUiAction.DenyDeviceAuthorization("ABCD-EFGH").toString().contains("ABCD-EFGH")) + + val switched = reduceIdentityUiState(initial, IdentityUiAction.SelectOrganization(second.id)) + assertEquals(second.id, switched.organizationManagement.selectedOrganizationId) + assertTrue(switched.organizationManagement.memberships.isEmpty()) + + val deviceScoped = reduceIdentityUiState( + reduceIdentityUiState(switched, IdentityUiAction.SelectDeviceOrganization(first.id)), + IdentityUiAction.ToggleDeviceCapability(read, true) + ) + assertEquals(first.id, deviceScoped.deviceApproval?.selectedOrganizationId) + assertEquals(setOf(read), deviceScoped.deviceApproval?.selectedCapabilities) + + val changedDeviceOrganization = reduceIdentityUiState( + deviceScoped, + IdentityUiAction.SelectDeviceOrganization(second.id) + ) + assertEquals(emptySet(), changedDeviceOrganization.deviceApproval?.selectedCapabilities) + assertSame( + changedDeviceOrganization, + reduceIdentityUiState( + changedDeviceOrganization, + IdentityUiAction.ToggleDeviceCapability(read, true) + ) + ) + + val unknownOrganization = OrganizationId("org-not-offered") + assertSame( + deviceScoped, + reduceIdentityUiState(deviceScoped, IdentityUiAction.SelectDeviceOrganization(unknownOrganization)) + ) + assertSame( + deviceScoped, + reduceIdentityUiState( + deviceScoped, + IdentityUiAction.ToggleDeviceCapability(Capability("application.not_offered"), true) + ) + ) + } + + @Test + fun deviceApprovalRequiresExplicitOfferedOrganizationAndRequestedScopes() { + val organization = OrganizationUiModel( + OrganizationId("org-device"), + "Device organization", + "device-org", + OrganizationRole.PUBLISHER + ) + val capability = Capability.CONTENT_READ + + assertFailsWith { + DeviceApprovalUiState( + userCode = "ABCD-EFGH", + clientName = "CLI", + expiresAt = "2026-07-14T10:00:00Z", + organizations = listOf(organization), + approvableCapabilitiesByOrganization = mapOf(organization.id to setOf(capability)), + selectedOrganizationId = OrganizationId("org-other"), + capabilityOptions = listOf(CapabilityOptionUiModel(capability, "Read")) + ) + } + assertFailsWith { + DeviceApprovalUiState( + userCode = "ABCD-EFGH", + clientName = "CLI", + expiresAt = "2026-07-14T10:00:00Z", + organizations = listOf(organization), + approvableCapabilitiesByOrganization = mapOf(organization.id to setOf(capability)), + selectedOrganizationId = organization.id, + capabilityOptions = listOf(CapabilityOptionUiModel(capability, "Read")), + selectedCapabilities = setOf(Capability.CONTENT_PUBLISH) + ) + } + } + + @Test + fun organizationStateRejectsResourcesFromAnotherTenant() { + val selected = OrganizationUiModel( + OrganizationId("org-selected"), + "Selected organization", + "selected-org", + OrganizationRole.OWNER + ) + assertFailsWith { + OrganizationManagementUiState( + organizations = listOf(selected), + selectedOrganizationId = selected.id, + memberships = listOf( + MembershipUiModel( + id = MembershipId("membership-other"), + organizationId = OrganizationId("org-other"), + userId = UserId("user-other"), + displayName = "Other member", + role = OrganizationRole.VIEWER + ) + ) + ) + } + } + + private fun visibleRecoveryCodes(): RecoveryCodesUiState.VisibleOnce = + RecoveryCodesUiState.VisibleOnce((1..10).map { "recovery-code-$it" }) +} diff --git a/aether-auth-summon/src/commonTest/kotlin/codes/yousef/aether/auth/summon/PasskeyCeremonyClientTest.kt b/aether-auth-summon/src/commonTest/kotlin/codes/yousef/aether/auth/summon/PasskeyCeremonyClientTest.kt new file mode 100644 index 0000000..2bd2f47 --- /dev/null +++ b/aether-auth-summon/src/commonTest/kotlin/codes/yousef/aether/auth/summon/PasskeyCeremonyClientTest.kt @@ -0,0 +1,152 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.webauthn.AuthenticationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.AuthenticatorAssertionResponseDto +import codes.yousef.aether.auth.webauthn.AuthenticatorAttestationResponseDto +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialCreationOptions +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialRequestOptions +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialRpEntity +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialUserEntity +import codes.yousef.aether.auth.webauthn.RegistrationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.WebAuthnAuthenticationStartResponse +import codes.yousef.aether.auth.webauthn.WebAuthnRegistrationStartResponse +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class PasskeyCeremonyClientTest { + @Test + fun registrationPassesOnlyPublicOptionsAndOpaqueCeremonyId() = runTest { + val options = creationOptions() + val credential = registrationCredential() + val browser = RecordingBrowser(registration = credential) + val gateway = RecordingGateway(registrationOptions = options) + + PasskeyCeremonyClient(gateway, browser).register("Laptop") + + assertSame(options, browser.createdWith) + assertEquals("Laptop", gateway.registrationName) + assertEquals(ChallengeId("registration-ceremony"), gateway.finishedRegistrationId) + assertSame(credential, gateway.finishedRegistrationCredential) + } + + @Test + fun discoverableSignInAndStepUpPreservePurpose() = runTest { + val options = requestOptions() + val credential = authenticationCredential() + val browser = RecordingBrowser(authentication = credential) + val gateway = RecordingGateway(authenticationOptions = options) + val client = PasskeyCeremonyClient(gateway, browser) + + client.authenticate(PasskeyAuthenticationPurpose.DISCOVERABLE_SIGN_IN) + client.authenticate(PasskeyAuthenticationPurpose.STEP_UP) + + assertSame(options, browser.gotWith) + assertEquals( + listOf(PasskeyAuthenticationPurpose.DISCOVERABLE_SIGN_IN, PasskeyAuthenticationPurpose.STEP_UP), + gateway.startedPurposes + ) + assertEquals(gateway.startedPurposes, gateway.finishedPurposes) + assertEquals(ChallengeId("authentication-ceremony"), gateway.finishedAuthenticationId) + assertSame(credential, gateway.finishedAuthenticationCredential) + } + + private class RecordingBrowser( + private val registration: RegistrationPublicKeyCredentialDto = registrationCredential(), + private val authentication: AuthenticationPublicKeyCredentialDto = authenticationCredential() + ) : PasskeyBrowserClient { + var createdWith: PublicKeyCredentialCreationOptions? = null + var gotWith: PublicKeyCredentialRequestOptions? = null + + override suspend fun create(options: PublicKeyCredentialCreationOptions): RegistrationPublicKeyCredentialDto { + createdWith = options + return registration + } + + override suspend fun get(options: PublicKeyCredentialRequestOptions): AuthenticationPublicKeyCredentialDto { + gotWith = options + return authentication + } + } + + private class RecordingGateway( + private val registrationOptions: PublicKeyCredentialCreationOptions = creationOptions(), + private val authenticationOptions: PublicKeyCredentialRequestOptions = requestOptions() + ) : PasskeyCeremonyGateway { + var registrationName: String? = null + var finishedRegistrationId: ChallengeId? = null + var finishedRegistrationCredential: RegistrationPublicKeyCredentialDto? = null + val startedPurposes = mutableListOf() + val finishedPurposes = mutableListOf() + var finishedAuthenticationId: ChallengeId? = null + var finishedAuthenticationCredential: AuthenticationPublicKeyCredentialDto? = null + + override suspend fun startRegistration(passkeyName: String): WebAuthnRegistrationStartResponse { + registrationName = passkeyName + return WebAuthnRegistrationStartResponse(ChallengeId("registration-ceremony"), registrationOptions) + } + + override suspend fun finishRegistration( + ceremonyId: ChallengeId, + credential: RegistrationPublicKeyCredentialDto + ) { + finishedRegistrationId = ceremonyId + finishedRegistrationCredential = credential + } + + override suspend fun startAuthentication( + purpose: PasskeyAuthenticationPurpose + ): WebAuthnAuthenticationStartResponse { + startedPurposes += purpose + return WebAuthnAuthenticationStartResponse(ChallengeId("authentication-ceremony"), authenticationOptions) + } + + override suspend fun finishAuthentication( + ceremonyId: ChallengeId, + purpose: PasskeyAuthenticationPurpose, + credential: AuthenticationPublicKeyCredentialDto + ) { + finishedAuthenticationId = ceremonyId + finishedPurposes += purpose + finishedAuthenticationCredential = credential + } + } + + companion object { + private fun creationOptions() = PublicKeyCredentialCreationOptions( + challenge = "AQIDBA", + rp = PublicKeyCredentialRpEntity("login.example.test", "Example"), + user = PublicKeyCredentialUserEntity("dXNlcg", "person@example.test", "Person"), + timeout = 300_000 + ) + + private fun requestOptions() = PublicKeyCredentialRequestOptions( + challenge = "BQYHCA", + timeout = 300_000, + rpId = "login.example.test" + ) + + private fun registrationCredential() = RegistrationPublicKeyCredentialDto( + id = "Y3JlZGVudGlhbA", + rawId = "Y3JlZGVudGlhbA", + type = "public-key", + response = AuthenticatorAttestationResponseDto( + clientDataJSON = "Y2xpZW50", + attestationObject = "YXR0ZXN0YXRpb24" + ) + ) + + private fun authenticationCredential() = AuthenticationPublicKeyCredentialDto( + id = "Y3JlZGVudGlhbA", + rawId = "Y3JlZGVudGlhbA", + type = "public-key", + response = AuthenticatorAssertionResponseDto( + clientDataJSON = "Y2xpZW50", + authenticatorData = "YXV0aGVudGljYXRvcg", + signature = "c2lnbmF0dXJl" + ) + ) + } +} diff --git a/aether-auth-summon/src/jvmMain/kotlin/codes/yousef/aether/auth/summon/IdentitySsrRenderer.kt b/aether-auth-summon/src/jvmMain/kotlin/codes/yousef/aether/auth/summon/IdentitySsrRenderer.kt new file mode 100644 index 0000000..3f06900 --- /dev/null +++ b/aether-auth-summon/src/jvmMain/kotlin/codes/yousef/aether/auth/summon/IdentitySsrRenderer.kt @@ -0,0 +1,31 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.summon.modifier.Modifier +import codes.yousef.summon.runtime.PlatformRenderer + +/** + * Renders the common identity surface as a complete Summon hydration document. + * + * The state is rendered into escaped HTML only; it is not passed to Summon's generic state + * serializer. That prevents accidental serialization of future server-only state alongside the + * browser shell. + */ +class IdentitySsrRenderer( + private val rendererFactory: () -> PlatformRenderer = ::PlatformRenderer +) { + fun render( + state: IdentityUiState, + dispatcher: IdentityUiDispatcher, + language: String = "en", + direction: String = "ltr", + modifier: Modifier = Modifier() + ): String { + require(LANGUAGE_PATTERN.matches(language)) { "Invalid document language" } + require(direction == "ltr" || direction == "rtl") { "Invalid document direction" } + return rendererFactory().renderComposableRootWithHydration(language, direction) { + IdentityUi(state, dispatcher, modifier) + } + } +} + +private val LANGUAGE_PATTERN = Regex("[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*") diff --git a/aether-auth-summon/src/jvmTest/kotlin/codes/yousef/aether/auth/summon/IdentitySsrRendererTest.kt b/aether-auth-summon/src/jvmTest/kotlin/codes/yousef/aether/auth/summon/IdentitySsrRendererTest.kt new file mode 100644 index 0000000..5119ae5 --- /dev/null +++ b/aether-auth-summon/src/jvmTest/kotlin/codes/yousef/aether/auth/summon/IdentitySsrRendererTest.kt @@ -0,0 +1,301 @@ +package codes.yousef.aether.auth.summon + +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.CredentialId +import codes.yousef.aether.auth.InvitationId +import codes.yousef.aether.auth.InvitationState +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.ServiceCredentialId +import codes.yousef.aether.auth.ServiceCredentialState +import codes.yousef.aether.auth.ServiceIdentityId +import codes.yousef.aether.auth.ServiceIdentityState +import codes.yousef.aether.auth.SessionId +import codes.yousef.aether.auth.UserId +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class IdentitySsrRendererTest { + private val noOpDispatcher = IdentityUiDispatcher { } + + @Test + fun rendersHydratableResponsiveAndKeyboardAccessibleShell() { + val html = IdentitySsrRenderer().render(managementState(), noOpDispatcher) + + assertContains(html, "") + assertContains(html, "") + assertContains(html, "data-ssr=\"true\"") + assertContains(html, "id=\"summon-hydration-data\"") + assertContains(html, "Identity and security") + assertContains(html, "data-media-queries=\"(min-width: 768px)") + assertContains(html, "data-identity-action=\"discoverable-sign-in\"") + assertContains(html, "data-identity-action=\"rename-passkey\"") + assertContains(html, "data-identity-action=\"revoke-session\"") + assertContains(html, "data-identity-action=\"step-up\"") + assertContains(html, "tabindex=\"0\"") + assertContains(html, "aria-label=\"Sign in with a discoverable passkey\"") + assertContains(html, "aria-describedby=\"aether-registration-help\"") + assertContains(html, "data-onclick-id=") + assertContains(html, "box-sizing: border-box") + assertContains(html, "min-width: 0") + } + + @Test + fun errorAndOneTimeCodesHaveLiveRegionsAndManagedFocus() { + val errorHtml = IdentitySsrRenderer().render( + IdentityUiState(feedback = IdentityUiFeedback("Try again", IdentityUiFeedbackSeverity.ERROR)), + noOpDispatcher + ) + val recoveryHtml = IdentitySsrRenderer().render( + managementState().copy( + recoveryCodes = RecoveryCodesUiState.VisibleOnce((1..10).map { "code-$it-safe" }) + ), + noOpDispatcher + ) + val serviceCredentialHtml = IdentitySsrRenderer().render( + managementState().copy( + oneTimeSecret = OneTimeIdentitySecretUiState.VisibleOnce( + OneTimeIdentitySecretKind.SERVICE_CREDENTIAL, + "Credential svc_123456", + "svc_123456.abcdefghijklmnopqrstuvwxyz012345" + ) + ), + noOpDispatcher + ) + + assertContains(errorHtml, "role=\"alert\"") + assertContains(errorHtml, "aria-live=\"assertive\"") + assertContains(errorHtml, "autofocus=\"\"") + assertContains(recoveryHtml, "role=\"region\"") + assertContains(recoveryHtml, "aria-label=\"New recovery codes\"") + assertContains(recoveryHtml, "Save these codes now. They will not be shown again.") + assertContains(recoveryHtml, "code-10-safe") + assertContains(recoveryHtml, "autofocus=\"\"") + assertContains(serviceCredentialHtml, "aria-label=\"New one-time identity secret\"") + assertContains(serviceCredentialHtml, "svc_123456.abcdefghijklmnopqrstuvwxyz012345") + assertContains(serviceCredentialHtml, "data-identity-action=\"dismiss-one-time-secret\"") + assertContains(serviceCredentialHtml, "autofocus=\"\"") + } + + @Test + fun ssrEscapesUntrustedLabelsAndDoesNotSerializeAuthorityState() { + val html = IdentitySsrRenderer().render( + managementState().copy(signedInDisplayName = ""), + noOpDispatcher + ) + + assertFalse(html.contains("", "") + .replace( + "", + "" + ) + +private fun browserAssetRoot(): Path? = System.getProperty("aether.example.webAssets") + ?.takeIf { it.isNotBlank() } + ?.let(Path::of) + ?.toAbsolutePath() + ?.normalize() + +private suspend fun Exchange.respondBrowserAsset(root: Path?) { + val name = request.path.removePrefix("/identity-client/") + if (root == null || !Regex("[A-Za-z0-9_.-]{1,200}").matches(name)) { + notFound() + return + } + val file = root.resolve(name).normalize() + if (!file.startsWith(root) || !Files.isRegularFile(file) || Files.size(file) > MAX_BROWSER_ASSET_BYTES) { + notFound() + return + } + val contentType = when (file.fileName.toString().substringAfterLast('.', "")) { + "js" -> "text/javascript; charset=utf-8" + "wasm" -> "application/wasm" + "map" -> "application/json; charset=utf-8" + else -> "application/octet-stream" } - exchange.response.write(json) - exchange.response.end() + response.setHeader("Cache-Control", "no-store") + respondBytes(contentType = contentType, bytes = Files.readAllBytes(file)) } + +private suspend inline fun Exchange.respondJson(value: T) { + response.statusCode = 200 + response.setHeader("Content-Type", "application/json; charset=utf-8") + response.setHeader("Cache-Control", "no-store") + response.write(EXAMPLE_JSON.encodeToString(value)) + response.end() +} + +private const val MAX_BROWSER_ASSET_BYTES = 16L * 1024 * 1024 +private const val MAX_IDENTITY_REQUEST_BODY_BYTES = 1_048_576 +private val EXAMPLE_JSON = Json { encodeDefaults = true } diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/AuthenticationTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/AuthenticationTest.kt deleted file mode 100644 index 87b2657..0000000 --- a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/AuthenticationTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -package codes.yousef.aether.example - -import codes.yousef.aether.core.auth.* -import codes.yousef.aether.core.HttpMethod -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.* -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertNotNull - -/** - * Unit tests for Authentication functionality. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class AuthenticationTest { - - @Test - fun testUserPrincipal() { - val principal = UserPrincipal( - id = "user-123", - name = "John Doe", - roles = setOf("admin", "user") - ) - - assertEquals("user-123", principal.id) - assertEquals("John Doe", principal.name) - assertTrue(principal.hasRole("admin")) - assertTrue(principal.hasRole("user")) - assertFalse(principal.hasRole("guest")) - } - - @Test - fun testPrincipalRoleChecks() { - val principal = UserPrincipal( - id = "user-456", - name = "Jane Smith", - roles = setOf("admin", "editor", "viewer") - ) - - // Test hasAnyRole - assertTrue(principal.hasAnyRole("admin", "guest")) - assertTrue(principal.hasAnyRole("editor")) - assertFalse(principal.hasAnyRole("guest", "moderator")) - - // Test hasAllRoles - assertTrue(principal.hasAllRoles("admin", "editor")) - assertTrue(principal.hasAllRoles("viewer")) - assertFalse(principal.hasAllRoles("admin", "guest")) - } - - @Test - fun testAuthResultSuccess() { - val principal = UserPrincipal("id", "name", emptySet()) - val result = AuthResult.Success(principal) - - assertTrue(result.isSuccess) - assertFalse(result.isFailure) - assertEquals(principal, result.principalOrNull()) - } - - @Test - fun testAuthResultFailure() { - val result = AuthResult.Failure("Invalid credentials") - - assertFalse(result.isSuccess) - assertTrue(result.isFailure) - assertNull(result.principalOrNull()) - } - - @Test - fun testAuthResultNoCredentials() { - val result = AuthResult.NoCredentials - - assertFalse(result.isSuccess) - assertFalse(result.isFailure) - assertNull(result.principalOrNull()) - } - - @Test - fun testCredentialsTypes() { - // Test UsernamePassword - val userPass = Credentials.UsernamePassword("admin", "secret") - assertEquals("admin", userPass.username) - assertEquals("secret", userPass.password) - - // Test BearerToken - val bearer = Credentials.BearerToken("jwt-token-here") - assertEquals("jwt-token-here", bearer.token) - - // Test Basic - val basic = Credentials.Basic("user", "pass") - assertEquals("user", basic.username) - assertEquals("pass", basic.password) - - // Test ApiKey - val apiKey = Credentials.ApiKey("api-key-123", ApiKeySource.HEADER) - assertEquals("api-key-123", apiKey.key) - assertEquals(ApiKeySource.HEADER, apiKey.source) - } - - @Test - fun testAuthConfig() { - val config = AuthConfig( - realm = "MyApp", - required = true, - excludedPaths = setOf("/public", "/health"), - excludedPathPrefixes = setOf("/api/v1/public") - ) - - assertEquals("MyApp", config.realm) - assertTrue(config.required) - assertTrue("/public" in config.excludedPaths) - assertTrue("/api/v1/public" in config.excludedPathPrefixes) - } - - @Test - fun testJwtConfig() { - val config = JwtConfig( - secret = "my-secret-key", - issuer = "my-app", - audience = "api-users", - leewaySeconds = 60 - ) - - assertEquals("my-secret-key", config.secret) - assertEquals("my-app", config.issuer) - assertEquals("api-users", config.audience) - assertEquals(60, config.leewaySeconds) - } - - @Test - fun testApiKeyConfig() { - val config = ApiKeyConfig( - headerName = "X-API-Key", - queryParamName = "api_key", - sources = setOf(ApiKeySource.HEADER, ApiKeySource.QUERY_PARAM) - ) - - assertEquals("X-API-Key", config.headerName) - assertEquals("api_key", config.queryParamName) - assertTrue(ApiKeySource.HEADER in config.sources) - assertTrue(ApiKeySource.QUERY_PARAM in config.sources) - assertFalse(ApiKeySource.COOKIE in config.sources) - } - - @Test - fun testPrincipalAttributes() { - val principal = UserPrincipal( - id = "user-789", - name = "Bob", - roles = setOf("user"), - _attributes = mapOf("department" to "engineering", "level" to "senior") - ) - - assertEquals("engineering", principal.attributes["department"]) - assertEquals("senior", principal.attributes["level"]) - } -} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/CsrfProtectionTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/CsrfProtectionTest.kt deleted file mode 100644 index 28ccb97..0000000 --- a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/CsrfProtectionTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package codes.yousef.aether.example - -import codes.yousef.aether.core.HttpMethod -import codes.yousef.aether.core.security.* -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.* -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.test.assertFalse -import kotlin.test.assertNotEquals - -/** - * Unit tests for CSRF Protection functionality. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CsrfProtectionTest { - - @Test - fun testCsrfConfig() { - val config = CsrfConfig( - sessionKey = "_my_csrf", - headerName = "X-CSRF-Token", - formFieldName = "_csrf", - queryParamName = "csrf_token", - tokenLength = 64 - ) - - assertEquals("_my_csrf", config.sessionKey) - assertEquals("X-CSRF-Token", config.headerName) - assertEquals("_csrf", config.formFieldName) - assertEquals("csrf_token", config.queryParamName) - assertEquals(64, config.tokenLength) - } - - @Test - fun testDefaultCsrfConfig() { - val config = CsrfConfig() - - assertEquals("_csrf_token", config.sessionKey) - assertEquals("X-CSRF-Token", config.headerName) - assertEquals("_csrf", config.formFieldName) - assertEquals(32, config.tokenLength) - assertEquals(403, config.errorStatusCode) - } - - @Test - fun testProtectedMethods() { - val config = CsrfConfig() - - // Protected methods - assertTrue(HttpMethod.POST in config.protectedMethods) - assertTrue(HttpMethod.PUT in config.protectedMethods) - assertTrue(HttpMethod.DELETE in config.protectedMethods) - assertTrue(HttpMethod.PATCH in config.protectedMethods) - - // Safe methods - assertFalse(HttpMethod.GET in config.protectedMethods) - assertFalse(HttpMethod.HEAD in config.protectedMethods) - assertFalse(HttpMethod.OPTIONS in config.protectedMethods) - } - - @Test - fun testExcludedPaths() { - val config = CsrfConfig( - excludedPaths = setOf("/api/webhook", "/api/public"), - excludedPathPrefixes = setOf("/api/external/") - ) - - assertTrue("/api/webhook" in config.excludedPaths) - assertTrue("/api/public" in config.excludedPaths) - assertTrue("/api/external/" in config.excludedPathPrefixes) - } - - @Test - fun testTokenRotation() { - val withRotation = CsrfConfig(rotateTokenOnRequest = true) - val withoutRotation = CsrfConfig(rotateTokenOnRequest = false) - - assertTrue(withRotation.rotateTokenOnRequest) - assertFalse(withoutRotation.rotateTokenOnRequest) - } - - @Test - fun testErrorConfiguration() { - val config = CsrfConfig( - errorMessage = "Custom CSRF error", - errorStatusCode = 400 - ) - - assertEquals("Custom CSRF error", config.errorMessage) - assertEquals(400, config.errorStatusCode) - } -} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/ExampleIdentityAuthorityTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/ExampleIdentityAuthorityTest.kt new file mode 100644 index 0000000..39b5af7 --- /dev/null +++ b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/ExampleIdentityAuthorityTest.kt @@ -0,0 +1,168 @@ +package codes.yousef.aether.example + +import codes.yousef.aether.auth.BootstrapIdentityRequest +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.IdentityHttpApi +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.core.Attributes +import codes.yousef.aether.core.Cookie +import codes.yousef.aether.core.Cookies +import codes.yousef.aether.core.Exchange +import codes.yousef.aether.core.Headers +import codes.yousef.aether.core.HttpMethod +import codes.yousef.aether.core.Request +import codes.yousef.aether.core.RequestConnection +import codes.yousef.aether.core.Response +import codes.yousef.aether.core.pipeline.Pipeline +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ExampleIdentityAuthorityTest { + private val json = Json { encodeDefaults = true; explicitNulls = false } + + @Test + fun `development authority starts bootstraps and resolves its constrained enrollment cookie`() = runBlocking { + val secret = "example-integration-bootstrap-secret" + val authority = ExampleIdentityAuthority.create(port = 8080, bootstrapSecret = secret) + authority.start() + val pipeline = Pipeline().apply { + use(authority.identityMiddleware()) + use(authority.httpApi.asMiddleware()) + } + val bootstrap = ExampleExchange( + method = HttpMethod.POST, + path = IdentityHttpApi.BOOTSTRAP, + headers = Headers.of( + "Content-Type" to "application/json", + "Origin" to authority.config.publicBaseUrl + ), + body = json.encodeToString( + BootstrapIdentityRequest( + secret = secret, + displayName = "Example Owner", + primaryEmail = EmailAddress("owner@example.test"), + organizationName = "Example Organization", + organizationSlug = "example-org" + ) + ).encodeToByteArray() + ) + + pipeline.execute(bootstrap) { error("Bootstrap route must be handled") } + + assertEquals(201, bootstrap.response.statusCode) + val payload = Json.parseToJsonElement(bootstrap.response.bodyText()).jsonObject + val csrf = payload.getValue("csrfToken").jsonPrimitive.content + val sessionCookie = bootstrap.response.cookies.single { it.name == authority.config.cookie.name } + assertTrue(sessionCookie.secure) + assertTrue(sessionCookie.httpOnly) + assertEquals("/", sessionCookie.path) + assertFalse(bootstrap.response.bodyText().contains(secret)) + assertFalse(bootstrap.response.bodyText().contains(sessionCookie.value)) + + val registration = ExampleExchange( + method = HttpMethod.POST, + path = IdentityHttpApi.REGISTRATION_START, + headers = Headers.of( + "Origin" to authority.config.publicBaseUrl, + "X-CSRF-Token" to csrf + ), + cookies = Cookies.of(Cookie(authority.config.cookie.name, sessionCookie.value)) + ) + pipeline.execute(registration) { error("Registration route must be handled") } + + assertEquals(200, registration.response.statusCode) + assertTrue(registration.response.cookies.any { it.name == "__Host-aether_ceremony" }) + val snapshot = authority.store.snapshot() + assertTrue(snapshot.bootstrapCompleted) + assertEquals(1, snapshot.users.size) + assertEquals(1, snapshot.organizations.size) + assertEquals(1, snapshot.sessions.size) + } + + @Test + fun `organization selector accepts only canonical IDs in explicit organization routes`() { + val organizationId = OrganizationId("01900000-0000-7000-8000-000000000099") + val explicit = ExampleExchange( + method = HttpMethod.GET, + path = "/identity/v1/organizations/${organizationId.value}/memberships" + ) + val deviceApproval = ExampleExchange(HttpMethod.POST, "/identity/v1/device/approve") + val malformed = ExampleExchange(HttpMethod.GET, "/identity/v1/organizations/not-a-uuid") + + assertEquals(organizationId, organizationFromExplicitRoute(explicit)) + assertEquals(null, organizationFromExplicitRoute(deviceApproval)) + assertEquals(null, organizationFromExplicitRoute(malformed)) + } + + @Test + fun `development recovery boundary throttles repeated attempts by pseudonymous direct peer`() = runBlocking { + val authority = ExampleIdentityAuthority.create( + port = 8080, + bootstrapSecret = "example-integration-bootstrap-secret" + ) + authority.start() + val pipeline = Pipeline().apply { use(authority.httpApi.asMiddleware()) } + + val codes = (1..6).map { + val exchange = ExampleExchange( + method = HttpMethod.POST, + path = IdentityHttpApi.RECOVERY_CODE_USE, + headers = Headers.of("Content-Type" to "application/json"), + body = """{"code":"AAAA-BBBB-CCCC-DDDD-EEEE-FFFF"}""".encodeToByteArray(), + connection = RequestConnection(peerAddress = "203.0.113.9") + ) + pipeline.execute(exchange) { error("Recovery route must be handled") } + Json.parseToJsonElement(exchange.response.bodyText()).jsonObject + .getValue("error").jsonObject.getValue("code").jsonPrimitive.content + } + + assertFalse(codes.take(5).contains("rate_limited")) + assertEquals("rate_limited", codes.last()) + } +} + +private class ExampleExchange( + method: HttpMethod, + path: String, + headers: Headers = Headers.Empty, + cookies: Cookies = Cookies.Empty, + body: ByteArray = ByteArray(0), + connection: RequestConnection = RequestConnection() +) : Exchange { + override val request: Request = object : Request { + override val method = method + override val uri = path + override val path = path + override val query: String? = null + override val headers = headers + override val cookies = cookies + override val connection = connection + override suspend fun bodyBytes(): ByteArray = body.copyOf() + } + override val response = ExampleResponse() + override val attributes = Attributes() +} + +private class ExampleResponse : Response { + override var statusCode: Int = 200 + override var statusMessage: String? = null + override val headers = Headers.HeadersBuilder() + override val cookies = mutableListOf() + private val body = mutableListOf() + + override suspend fun write(data: ByteArray) { + body.addAll(data.toList()) + } + + override suspend fun end() = Unit + + fun bodyText(): String = body.toByteArray().decodeToString() +} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IdentityEntrySsrTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IdentityEntrySsrTest.kt new file mode 100644 index 0000000..f8de828 --- /dev/null +++ b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IdentityEntrySsrTest.kt @@ -0,0 +1,31 @@ +package codes.yousef.aether.example + +import codes.yousef.summon.runtime.PlatformRenderer +import kotlin.test.Test +import kotlin.test.assertContains + +class IdentityEntrySsrTest { + @Test + fun `bootstrap SSR preserves password email and stable field attributes`() { + val html = PlatformRenderer().renderComposableRootWithHydration("en", "ltr") { + BootstrapIdentityUi(BootstrapIdentityUiState(), BootstrapIdentityUiDispatcher { }) + } + + assertContains(html, "type=\"password\"") + assertContains(html, "id=\"aether-bootstrap-secret\"") + assertContains(html, "name=\"aether-bootstrap-secret\"") + assertContains(html, "type=\"email\"") + assertContains(html, "id=\"aether-bootstrap-email\"") + } + + @Test + fun `recovery SSR never renders its recovery code as plain text`() { + val html = PlatformRenderer().renderComposableRootWithHydration("en", "ltr") { + RecoveryIdentityUi(RecoveryIdentityUiState(), RecoveryIdentityUiDispatcher { }) + } + + assertContains(html, "type=\"password\"") + assertContains(html, "id=\"aether-recovery-code\"") + assertContains(html, "name=\"aether-recovery-code\"") + } +} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IdentityExampleContractTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IdentityExampleContractTest.kt new file mode 100644 index 0000000..c20525d --- /dev/null +++ b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IdentityExampleContractTest.kt @@ -0,0 +1,248 @@ +package codes.yousef.aether.example + +import codes.yousef.aether.auth.ApproveDeviceGrantRequest +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.CredentialId +import codes.yousef.aether.auth.DeviceGrantId +import codes.yousef.aether.auth.DeviceGrantState +import codes.yousef.aether.auth.DeviceGrantView +import codes.yousef.aether.auth.InspectDeviceGrantRequest +import codes.yousef.aether.auth.InvitationId +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.OrganizationAccessView +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.PasskeyAuthenticationFinishRequest +import codes.yousef.aether.auth.PasskeyRegistrationFinishRequest +import codes.yousef.aether.auth.ServiceCredentialId +import codes.yousef.aether.auth.ServiceIdentityId +import codes.yousef.aether.auth.SessionId +import codes.yousef.aether.auth.summon.PasskeyBrowserClient +import codes.yousef.aether.auth.summon.PasskeyCeremonyClient +import codes.yousef.aether.auth.summon.PasskeyCeremonyGateway +import codes.yousef.aether.auth.webauthn.AuthenticationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.AuthenticatorAssertionResponseDto +import codes.yousef.aether.auth.webauthn.AuthenticatorAttestationResponseDto +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialCreationOptions +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialRequestOptions +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialRpEntity +import codes.yousef.aether.auth.webauthn.PublicKeyCredentialUserEntity +import codes.yousef.aether.auth.webauthn.RegistrationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.WebAuthnAuthenticationStartResponse +import codes.yousef.aether.auth.webauthn.WebAuthnRegistrationStartResponse +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IdentityExampleContractTest { + private val organizationId = OrganizationId("018f47d2-8d4d-7abc-8def-1234567890ab") + private val json = Json { encodeDefaults = true } + + @Test + fun `example exposes fixed passkey organization and device routes`() { + val contract = IdentityExampleContract() + val encoded = json.encodeToString(contract).lowercase() + + assertTrue("/identity/v1/passkeys" in encoded) + assertTrue("/identity/v1/passkeys/step-up/start" in encoded) + assertTrue("/identity/v1/passkeys/step-up/finish" in encoded) + assertTrue("/identity/v1/bootstrap" in encoded) + assertTrue("/identity/v1/recovery/codes/use" in encoded) + assertTrue("/identity/v1/recovery/codes/replace" in encoded) + assertTrue("/identity/v1/invitations/enroll" in encoded) + assertTrue("/identity/v1/device/approve" in encoded) + assertTrue("/identity/v1/device/deny" in encoded) + assertTrue("/oauth/device_authorization" in encoded) + assertTrue("/oauth/token" in encoded) + assertTrue(Capability("package.publish") in contract.serviceCredentialCapabilities) + assertTrue(contract.serviceCredentialCapabilities.none { + it in Capability.IDENTITY_MANAGEMENT || it == Capability.ACCOUNT_RECOVERY_ADMIN + }) + assertEquals( + "/identity/v1/organizations/${organizationId.value}/memberships", + contract.memberships(organizationId) + ) + assertEquals( + "/identity/v1/organizations/${organizationId.value}/service-identities", + contract.serviceIdentities(organizationId) + ) + assertEquals("/identity/v1/passkeys/credential-id", contract.passkey(CredentialId("credential-id"))) + assertEquals("/identity/v1/sessions/session-id", contract.session(SessionId("session-id"))) + assertEquals( + "/identity/v1/organizations/${organizationId.value}/memberships/membership-id", + contract.membership(organizationId, MembershipId("membership-id")) + ) + assertEquals( + "/identity/v1/organizations/${organizationId.value}/invitations/invitation-id", + contract.invitation(organizationId, InvitationId("invitation-id")) + ) + assertEquals( + "/identity/v1/organizations/${organizationId.value}/service-identities/service-id/credentials/" + + "credential-id/rotate", + contract.rotateServiceCredential( + organizationId, + ServiceIdentityId("service-id"), + ServiceCredentialId("credential-id") + ) + ) + assertEquals("/identity/bootstrap", contract.bootstrapUi) + assertEquals("/identity/recovery", contract.recoveryUi) + assertFalse("password" in encoded) + assertFalse("jwt" in encoded) + assertFalse("selectedorganization" in encoded) + } + + @Test + fun `wire requests exactly match the strict authority DTOs`() { + val credential = RecordingBrowser().createCredential() + val registration = PasskeyRegistrationFinishRequest( + ceremonyId = ChallengeId("018f47d2-8d4d-7abc-8def-1234567890ac"), + credentialName = "Laptop", + credential = credential + ) + val registrationJson = json.encodeToString(registration) + assertTrue("\"credentialName\":\"Laptop\"" in registrationJson) + + val authentication = PasskeyAuthenticationFinishRequest( + ceremonyId = ChallengeId("018f47d2-8d4d-7abc-8def-1234567890ad"), + credential = RecordingBrowser().getCredential() + ) + assertFalse("purpose" in json.encodeToString(authentication)) + + val approval = ApproveDeviceGrantRequest( + userCode = "ABCD-2345", + organizationId = organizationId, + capabilities = setOf(Capability("package.publish")) + ) + assertEquals( + "{\"userCode\":\"ABCD-2345\",\"organizationId\":\"${organizationId.value}\"," + + "\"capabilities\":[\"package.publish\"]}", + json.encodeToString(approval) + ) + assertEquals( + "{\"userCode\":\"ABCD-2345\"}", + json.encodeToString(InspectDeviceGrantRequest("ABCD-2345")) + ) + } + + @Test + fun `reference client composes passkey organization and explicit device approval flows`() = runBlocking { + val gateway = RecordingGateway(organizationId) + val browser = RecordingBrowser() + val client = IdentityExampleClient(PasskeyCeremonyClient(gateway, browser), gateway) + + client.registerPasskey("Laptop") + client.discoverableSignIn() + val organizations = client.organizations() + val pendingGrant = client.resolveDevice("ABCD-2345") + client.approveDevice("ABCD-2345", organizationId, setOf(Capability("package.publish"))) + + assertEquals("Laptop", gateway.registrationName) + assertTrue(gateway.registrationFinished) + assertTrue(gateway.authenticationFinished) + assertEquals(1, organizations.size) + assertEquals("ABCD-2345", gateway.inspection?.userCode) + assertEquals("Aether CLI", pendingGrant.clientName) + assertEquals(organizationId, gateway.approval?.organizationId) + assertEquals(setOf(Capability("package.publish")), gateway.approval?.capabilities) + } + + private class RecordingGateway( + private val organizationId: OrganizationId + ) : PasskeyCeremonyGateway, IdentityExampleApi { + var registrationName: String? = null + var registrationFinished = false + var authenticationFinished = false + var inspection: InspectDeviceGrantRequest? = null + var approval: ApproveDeviceGrantRequest? = null + + override suspend fun startRegistration(passkeyName: String): WebAuthnRegistrationStartResponse { + registrationName = passkeyName + return WebAuthnRegistrationStartResponse( + ceremonyId = ChallengeId("018f47d2-8d4d-7abc-8def-1234567890ac"), + publicKey = PublicKeyCredentialCreationOptions( + challenge = "AQID", + rp = PublicKeyCredentialRpEntity("localhost", "Aether example"), + user = PublicKeyCredentialUserEntity("dXNlcg", "user@example.test", "Example User"), + timeout = 300_000 + ) + ) + } + + override suspend fun finishRegistration( + ceremonyId: ChallengeId, + credential: RegistrationPublicKeyCredentialDto + ) { + registrationFinished = true + } + + override suspend fun startAuthentication( + purpose: codes.yousef.aether.auth.summon.PasskeyAuthenticationPurpose + ): WebAuthnAuthenticationStartResponse = WebAuthnAuthenticationStartResponse( + ceremonyId = ChallengeId("018f47d2-8d4d-7abc-8def-1234567890ad"), + publicKey = PublicKeyCredentialRequestOptions( + challenge = "BAUG", + timeout = 300_000, + rpId = "localhost" + ) + ) + + override suspend fun finishAuthentication( + ceremonyId: ChallengeId, + purpose: codes.yousef.aether.auth.summon.PasskeyAuthenticationPurpose, + credential: AuthenticationPublicKeyCredentialDto + ) { + authenticationFinished = true + } + + override suspend fun listOrganizations(): List = listOf( + OrganizationAccessView( + id = organizationId, + name = "Example Organization", + slug = "example-org", + role = "publisher" + ) + ) + + override suspend fun inspectDevice(request: InspectDeviceGrantRequest): DeviceGrantView { + inspection = request + return DeviceGrantView( + id = DeviceGrantId("018f47d2-8d4d-7abc-8def-1234567890ae"), + clientName = "Aether CLI", + requestedCapabilities = setOf(Capability("package.publish")), + state = DeviceGrantState.PENDING, + createdAt = Instant.parse("2026-07-15T00:00:00Z"), + expiresAt = Instant.parse("2026-07-15T00:10:00Z") + ) + } + + override suspend fun approveDevice(request: ApproveDeviceGrantRequest) { + approval = request + } + } + + private class RecordingBrowser : PasskeyBrowserClient { + fun createCredential() = RegistrationPublicKeyCredentialDto( + id = "AQID", + rawId = "AQID", + type = "public-key", + response = AuthenticatorAttestationResponseDto("AQID", "AQID") + ) + + fun getCredential() = AuthenticationPublicKeyCredentialDto( + id = "AQID", + rawId = "AQID", + type = "public-key", + response = AuthenticatorAssertionResponseDto("AQID", "AQID", "AQID") + ) + + override suspend fun create(options: PublicKeyCredentialCreationOptions) = createCredential() + + override suspend fun get(options: PublicKeyCredentialRequestOptions) = getCredential() + } +} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IntegrationTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IntegrationTest.kt deleted file mode 100644 index e3a4d1a..0000000 --- a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/IntegrationTest.kt +++ /dev/null @@ -1,363 +0,0 @@ -package codes.yousef.aether.example - -import codes.yousef.aether.core.AetherDispatcher -import codes.yousef.aether.core.jvm.VertxServer -import codes.yousef.aether.core.jvm.VertxServerConfig -import codes.yousef.aether.core.pipeline.Pipeline -import codes.yousef.aether.core.pipeline.installCallLogging -import codes.yousef.aether.core.pipeline.installContentNegotiation -import codes.yousef.aether.core.pipeline.installRecovery -import codes.yousef.aether.db.DatabaseDriverRegistry -import codes.yousef.aether.db.jvm.VertxPgDriver -import codes.yousef.aether.web.router -import codes.yousef.aether.web.pathParam -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.* -import org.testcontainers.containers.PostgreSQLContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * Comprehensive integration tests for the Aether example application. - * Uses TestContainers for a real PostgreSQL database. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class IntegrationTest { - - companion object { - private val postgres = PostgreSQLContainer("postgres:16-alpine").apply { - withDatabaseName("test_db") - withUsername("test_user") - withPassword("test_password") - } - } - - private lateinit var server: VertxServer - private lateinit var driver: VertxPgDriver - private val httpClient = HttpClient.newHttpClient() - private val testPort = 8081 - private val baseUrl = "http://localhost:$testPort" - - @BeforeAll - fun setup() = runBlocking(AetherDispatcher.dispatcher) { - // Start PostgreSQL container - try { - postgres.start() - } catch (e: Throwable) { - Assumptions.assumeTrue(false, "Docker is not available: ${e.message}") - } - - // Initialize database driver - driver = VertxPgDriver.create( - host = postgres.host, - port = postgres.getMappedPort(5432), - database = postgres.databaseName, - user = postgres.username, - password = postgres.password - ) - DatabaseDriverRegistry.initialize(driver) - - // Create tables - Users.createTable() - - // Insert test data - User.create("alice", "alice@example.com", 30) - User.create("bob", "bob@example.com", 25) - User.create("charlie", "charlie@example.com", null) - - // Create router - val router = router { - get("/") { exchange -> - exchange.respond(200, "Welcome to Aether") - } - - get("/users") { exchange -> - val users = User.all() - val html = buildString { - append("

Users

    ") - for (user in users) { - append("
  • ${user.username}
  • ") - } - append("
") - } - exchange.respondHtml(200, html) - } - - get("/users/:id") { exchange -> - val userId = exchange.pathParam("id")?.toLongOrNull() - if (userId == null) { - exchange.badRequest("Invalid ID") - return@get - } - - val user = User.findById(userId) - if (user == null) { - exchange.notFound("User not found") - return@get - } - - exchange.respond(200, "User: ${user.username}") - } - - get("/api/users") { exchange -> - val users = User.all() - val json = Json.encodeToString( - kotlinx.serialization.builtins.ListSerializer(UserDto.serializer()), - users.map { UserDto(it.id, it.username, it.email, it.age) } - ) - exchange.response.statusCode = 200 - exchange.response.setHeader("Content-Type", "application/json") - exchange.response.write(json) - exchange.response.end() - } - - post("/api/users") { exchange -> - val body = exchange.request.bodyBytes().decodeToString() - val request = Json.decodeFromString(body) - - if (request.username.isNullOrBlank() || request.email.isNullOrBlank()) { - exchange.badRequest("Username and email required") - return@post - } - - val user = User.create(request.username, request.email, request.age) - val json = Json.encodeToString( - UserDto.serializer(), - UserDto(user.id, user.username, user.email, user.age) - ) - exchange.response.statusCode = 201 - exchange.response.setHeader("Content-Type", "application/json") - exchange.response.write(json) - exchange.response.end() - } - - get("/complex/:userId/posts/:postId") { exchange -> - val userId = exchange.pathParam("userId") - val postId = exchange.pathParam("postId") - exchange.respond(200, "User: $userId, Post: $postId") - } - } - - // Create pipeline - val pipeline = Pipeline().apply { - installRecovery() - installCallLogging() - installContentNegotiation() - use(router.asMiddleware()) - } - - // Create and start server - val config = VertxServerConfig(port = testPort) - server = VertxServer(config, pipeline) { exchange -> - exchange.notFound("Not Found") - } - server.start() - - // Wait a bit for server to be ready - Thread.sleep(500) - } - - @AfterAll - fun teardown() = runBlocking(AetherDispatcher.dispatcher) { - if (::server.isInitialized) { - server.stop() - } - if (::driver.isInitialized) { - driver.close() - } - postgres.stop() - } - - @Test - fun testHomePage() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(200, response.statusCode(), "Home page should return 200") - assertTrue(response.body().contains("Welcome to Aether"), "Response should contain welcome message") - } - - @Test - fun testListUsers() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/users")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(200, response.statusCode(), "Users page should return 200") - assertTrue(response.body().contains(""), "Response should be HTML") - assertTrue(response.body().contains("Users"), "Response should contain users") - assertTrue(response.body().contains("alice"), "Response should contain test user alice") - assertTrue(response.body().contains("bob"), "Response should contain test user bob") - } - - @Test - fun testUserById() = runBlocking { - val users = User.all() - val firstUser = users.first() - - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/users/${firstUser.id}")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(200, response.statusCode(), "User detail page should return 200") - assertTrue(response.body().contains("User: ${firstUser.username}"), "Response should contain username") - } - - @Test - fun testUserNotFound() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/users/99999")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(404, response.statusCode(), "Non-existent user should return 404") - assertTrue(response.body().contains("User not found"), "Response should contain not found message") - } - - @Test - fun testApiListUsers() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/api/users")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(200, response.statusCode(), "API should return 200") - assertEquals("application/json", response.headers().firstValue("Content-Type").orElse(""), "Content-Type should be JSON") - - val users = Json.decodeFromString>(response.body()) - assertTrue(users.size >= 3, "Should have at least 3 test users") - assertTrue(users.any { it.username == "alice" }, "Should contain alice") - assertTrue(users.any { it.username == "bob" }, "Should contain bob") - assertTrue(users.any { it.username == "charlie" }, "Should contain charlie") - } - - @Test - fun testApiCreateUser() { - val newUser = CreateUserRequest( - username = "david", - email = "david@example.com", - age = 35 - ) - val requestBody = Json.encodeToString(CreateUserRequest.serializer(), newUser) - - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/api/users")) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(requestBody)) - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(201, response.statusCode(), "User creation should return 201") - - val createdUser = Json.decodeFromString(response.body()) - assertNotNull(createdUser.id, "Created user should have an ID") - assertEquals("david", createdUser.username, "Username should match") - assertEquals("david@example.com", createdUser.email, "Email should match") - assertEquals(35, createdUser.age, "Age should match") - } - - @Test - fun testDatabaseOperations() = runBlocking { - // Test User.create() - val newUser = User.create("testuser", "test@example.com", 28) - assertNotNull(newUser.id, "Created user should have an ID") - assertEquals("testuser", newUser.username) - - // Test User.findById() - val foundUser = User.findById(newUser.id!!) - assertNotNull(foundUser, "Should find user by ID") - assertEquals("testuser", foundUser?.username) - - // Test User.save() - update - foundUser?.age = 29 - foundUser?.save() - - val updatedUser = User.findById(newUser.id!!) - assertEquals(29, updatedUser?.age, "Age should be updated") - - // Test User.findByUsername() - val userByUsername = User.findByUsername("testuser") - assertNotNull(userByUsername, "Should find user by username") - assertEquals("testuser", userByUsername?.username) - - // Test User.delete() - foundUser?.delete() - val deletedUser = User.findById(newUser.id!!) - assertEquals(null, deletedUser, "User should be deleted") - } - - @Test - fun testRoutingWithMultipleParams() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/complex/123/posts/456")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(200, response.statusCode(), "Complex route should return 200") - assertTrue(response.body().contains("User: 123"), "Response should contain userId parameter") - assertTrue(response.body().contains("Post: 456"), "Response should contain postId parameter") - } - - @Test - fun testInvalidRouteReturns404() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/nonexistent/route")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(404, response.statusCode(), "Invalid route should return 404") - } - - @Test - fun testBadRequestHandling() { - val request = HttpRequest.newBuilder() - .uri(URI.create("$baseUrl/users/invalid")) - .GET() - .build() - - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) - - assertEquals(400, response.statusCode(), "Invalid ID should return 400") - assertTrue(response.body().contains("Invalid ID"), "Response should contain error message") - } - - @AfterAll - fun tearDown() { - runBlocking { - if (::server.isInitialized) { - server.stop() - } - if (::driver.isInitialized) { - driver.close() - } - } - postgres.stop() - } -} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/SeenIdentityContractFixtureTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/SeenIdentityContractFixtureTest.kt new file mode 100644 index 0000000..71c45a9 --- /dev/null +++ b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/SeenIdentityContractFixtureTest.kt @@ -0,0 +1,148 @@ +package codes.yousef.aether.example + +import codes.yousef.aether.auth.IdentityErrorEnvelope +import codes.yousef.aether.auth.OAuthDeviceErrorCode +import codes.yousef.aether.auth.OAuthDeviceErrorResponse +import codes.yousef.aether.auth.PasskeyAuthenticationFinishRequest +import codes.yousef.aether.auth.webauthn.WebAuthnRegistrationStartResponse +import kotlin.time.Instant +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SeenIdentityContractFixtureTest { + private val json = Json { ignoreUnknownKeys = false } + private val prefix = "seen-fel-634/" + + @Test + fun `manifest enumerates every published Seen fixture`() { + val manifest = decode("manifest.json") + + assertEquals(1, manifest.schemaVersion) + assertEquals("0.6.0.0", manifest.identityRelease) + assertEquals("FEL-634", manifest.consumerIssue) + assertEquals(EXPECTED_FIXTURES, manifest.files.toSet()) + manifest.files.forEach { resource(it) } + } + + @Test + fun `protocol fixtures decode with public identity DTOs`() { + val registration = decode("passkey-registration-start.json") + val authentication = decode("passkey-authentication-finish.json") + val pending = decode("device-authorization-pending.json") + val notFound = decode("not-found-error.json") + + assertEquals("required", registration.publicKey.authenticatorSelection.residentKey) + assertTrue(registration.publicKey.authenticatorSelection.requireResidentKey) + assertEquals("required", registration.publicKey.authenticatorSelection.userVerification) + assertEquals(-7, registration.publicKey.pubKeyCredParams.single().alg) + assertEquals("public-key", authentication.credential.type) + assertEquals(OAuthDeviceErrorCode.AUTHORIZATION_PENDING, pending.error) + assertEquals(OAuthDeviceErrorCode.AUTHORIZATION_PENDING.publicMessage, pending.message) + assertTrue(pending.retryable) + assertTrue(pending.requestId.startsWith("req_")) + assertEquals("not_found", notFound.error.code.wireName) + } + + @Test + fun `authorization fixtures are explicit organization scoped and secret free`() { + val user = decode("user-publisher-context.json") + val device = decode("device-publisher-context.json") + val service = decode("service-publisher-context.json") + + assertEquals("publisher", user.organization.role) + assertEquals("user", user.principal.kind) + assertEquals("device", device.principal.kind) + assertEquals("service", service.principal.kind) + listOf(user, device, service).forEach { fixture -> + assertEquals(1, fixture.schemaVersion) + assertTrue("package.publish" in fixture.capabilities) + assertEquals(fixture.capabilities.sorted(), fixture.capabilities) + Instant.parse(fixture.principal.authenticatedAt) + } + + EXPECTED_FIXTURES.forEach { name -> + val element = json.parseToJsonElement(resource(name)) + assertSecretFree(element) + } + } + + private inline fun decode(name: String): T = json.decodeFromString(resource(name)) + + private fun resource(name: String): String = requireNotNull( + javaClass.classLoader.getResource(prefix + name) + ) { "Missing identity contract fixture $name" }.readText() + + private fun assertSecretFree(element: JsonElement) { + if (element is JsonObject) { + val normalizedKeys = element.keys.map { it.lowercase() } + FORBIDDEN_KEYS.forEach { forbidden -> + assertFalse(normalizedKeys.any { it == forbidden }, "Fixture exposed forbidden field $forbidden") + } + } + when (element) { + is JsonObject -> element.values.forEach(::assertSecretFree) + is kotlinx.serialization.json.JsonArray -> element.forEach(::assertSecretFree) + else -> Unit + } + } + + @Serializable + private data class FixtureManifest( + val schemaVersion: Int, + val identityRelease: String, + val consumerIssue: String, + val files: List + ) + + @Serializable + private data class SeenAuthorizationFixture( + val schemaVersion: Int, + val principal: SeenPrincipalFixture, + val organization: SeenOrganizationFixture, + val capabilities: List + ) + + @Serializable + private data class SeenPrincipalFixture( + val kind: String, + val userId: String? = null, + val serviceIdentityId: String? = null, + val displayName: String, + val assurance: String, + val authenticatedAt: String + ) + + @Serializable + private data class SeenOrganizationFixture(val id: String, val role: String? = null) + + private companion object { + val EXPECTED_FIXTURES = setOf( + "passkey-registration-start.json", + "passkey-authentication-finish.json", + "user-publisher-context.json", + "device-publisher-context.json", + "service-publisher-context.json", + "device-authorization-pending.json", + "not-found-error.json" + ) + val FORBIDDEN_KEYS = setOf( + "password", + "passwordhash", + "token", + "accesstoken", + "refreshtoken", + "tokendigest", + "secretdigest", + "recoverycode", + "assertion", + "rawip" + ) + } +} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/SessionManagementTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/SessionManagementTest.kt deleted file mode 100644 index 3a11333..0000000 --- a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/SessionManagementTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package codes.yousef.aether.example - -import codes.yousef.aether.core.session.* -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.* -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertNotNull - -/** - * Unit tests for Session Management functionality. - */ -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class SessionManagementTest { - - private lateinit var sessionStore: InMemorySessionStore - - @BeforeEach - fun setup() { - sessionStore = InMemorySessionStore() - } - - @Test - fun testSessionCreation() = runBlocking { - val session = DefaultSession( - id = "test-session-123", - createdAt = System.currentTimeMillis() - ) - - assertEquals("test-session-123", session.id) - assertTrue(session.isNew) - assertFalse(session.isInvalidated) - } - - @Test - fun testSessionDataStorage() = runBlocking { - val session = DefaultSession( - id = "test-session-456", - createdAt = System.currentTimeMillis() - ) - - // Test setting and getting values - session.set("user", "testuser") - session.set("role", "admin") - session.set("count", 42) - - assertEquals("testuser", session.getString("user")) - assertEquals("admin", session.getString("role")) - assertEquals(42, session.getInt("count")) - } - - @Test - fun testSessionInvalidation() = runBlocking { - val session = DefaultSession( - id = "test-session-789", - createdAt = System.currentTimeMillis() - ) - - session.set("data", "important") - assertFalse(session.isInvalidated) - - session.invalidate() - assertTrue(session.isInvalidated) - } - - @Test - fun testSessionStore() = runBlocking { - val session = DefaultSession( - id = "stored-session", - createdAt = System.currentTimeMillis() - ) - session.set("key", "value") - - // Save session - sessionStore.save(session) - - // Retrieve session - val retrieved = sessionStore.get("stored-session") - assertNotNull(retrieved) - assertEquals("stored-session", retrieved.id) - assertEquals("value", retrieved.getString("key")) - } - - @Test - fun testSessionStoreDelete() = runBlocking { - val session = DefaultSession( - id = "to-delete", - createdAt = System.currentTimeMillis() - ) - - sessionStore.save(session) - assertNotNull(sessionStore.get("to-delete")) - - sessionStore.delete("to-delete") - assertNull(sessionStore.get("to-delete")) - } - - @Test - fun testSessionKeys() = runBlocking { - val session = DefaultSession( - id = "keys-test", - createdAt = System.currentTimeMillis() - ) - - session.set("key1", "value1") - session.set("key2", "value2") - session.set("key3", "value3") - - val keys = session.keys() - assertEquals(3, keys.size) - assertTrue("key1" in keys) - assertTrue("key2" in keys) - assertTrue("key3" in keys) - } - - @Test - fun testSessionRemove() = runBlocking { - val session = DefaultSession( - id = "remove-test", - createdAt = System.currentTimeMillis() - ) - - session.set("toRemove", "data") - assertNotNull(session.get("toRemove")) - - val removed = session.remove("toRemove") - assertEquals("data", removed) - assertNull(session.get("toRemove")) - } - - @Test - fun testSessionClear() = runBlocking { - val session = DefaultSession( - id = "clear-test", - createdAt = System.currentTimeMillis() - ) - - session.set("key1", "value1") - session.set("key2", "value2") - - assertEquals(2, session.keys().size) - - session.clear() - - assertEquals(0, session.keys().size) - } - - @Test - fun testSessionConfig() { - val config = SessionConfig( - cookieName = "MY_SESSION", - maxAge = 7200L, - secure = true, - httpOnly = true, - sameSite = SameSitePolicy.STRICT - ) - - assertEquals("MY_SESSION", config.cookieName) - assertEquals(7200L, config.maxAge) - assertTrue(config.secure) - assertTrue(config.httpOnly) - assertEquals(SameSitePolicy.STRICT, config.sameSite) - } - - @Test - fun testTypedSessionValues() = runBlocking { - val session = DefaultSession( - id = "typed-test", - createdAt = System.currentTimeMillis() - ) - - session.set("stringVal", "hello") - session.set("intVal", 42) - session.set("longVal", 1000000L) - session.set("boolVal", true) - session.set("doubleVal", 3.14) - - assertEquals("hello", session.getString("stringVal")) - assertEquals(42, session.getInt("intVal")) - assertEquals(1000000L, session.getLong("longVal")) - assertEquals(true, session.getBoolean("boolVal")) - assertEquals(3.14, session.getDouble("doubleVal")) - } -} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/app/E2ETest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/app/E2ETest.kt deleted file mode 100644 index 0575a1a..0000000 --- a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/app/E2ETest.kt +++ /dev/null @@ -1,226 +0,0 @@ -package codes.yousef.aether.example.app - -import codes.yousef.aether.auth.JwtService -import codes.yousef.aether.core.AttributeKey -import codes.yousef.aether.core.Exchange -import codes.yousef.aether.core.jvm.VertxServer -import codes.yousef.aether.core.jvm.VertxServerConfig -import codes.yousef.aether.web.router -import io.vertx.core.Vertx -import io.vertx.ext.web.client.WebClient -import io.vertx.junit5.VertxExtension -import io.vertx.junit5.VertxTestContext -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import java.net.ServerSocket - -@ExtendWith(VertxExtension::class) -class E2ETest { - - companion object { - private var port: Int = 0 - private var server: VertxServer? = null - private val userPrincipalKey = AttributeKey("UserPrincipal", String::class) - - @BeforeAll - @JvmStatic - fun setup() { - // Find a free port - val socket = ServerSocket(0) - port = socket.localPort - socket.close() - - val jwtSecret = "integration-test-secret" - val jwtIssuer = "aether-test" - - // Define router - val appRouter = router { - get("/public") { exchange -> - exchange.respond(200, "Public Area") - } - - get("/protected") { exchange -> - val principal = exchange.attributes.get(userPrincipalKey) - if (principal != null) { - exchange.respond(200, "Hello $principal") - } else { - exchange.respond(401, "Unauthorized") - } - } - - get("/rbac/admin") { exchange -> - val principal = exchange.attributes.get(userPrincipalKey) - if (principal == "admin") { - exchange.respond(200, "Admin Area") - } else if (principal != null) { - exchange.respond(403, "Forbidden") - } else { - exchange.respond(401, "Unauthorized") - } - } - - post("/login") { exchange -> - val token = JwtService.generateToken( - subject = "testuser", - secret = jwtSecret, - issuer = jwtIssuer, - expirationMillis = 3600000 - ) - exchange.respond(200, token) - } - - post("/login-admin") { exchange -> - val token = JwtService.generateToken( - subject = "admin", - secret = jwtSecret, - issuer = jwtIssuer, - expirationMillis = 3600000 - ) - exchange.respond(200, token) - } - } - - // Start Server - server = VertxServer.create( - config = VertxServerConfig(port = port), - pipeline = codes.yousef.aether.core.pipeline.Pipeline().apply { - use { exchange, next -> - val authHeader = exchange.request.headers["Authorization"] - if (authHeader != null && authHeader.startsWith("Bearer ")) { - val token = authHeader.substring(7) - try { - val payload = JwtService.verifyToken(token, jwtSecret, jwtIssuer) - if (payload != null) { - exchange.attributes.put(userPrincipalKey, payload.subject) - } - } catch (e: Exception) { - // Invalid token - } - } - next() - } - use(appRouter.asMiddleware()) - } - ) { exchange -> - // Fallback handler if no route matches (404) - exchange.respond(404, "Not Found") - } - - runBlocking { - server?.start() - } - } - - @AfterAll - @JvmStatic - fun tearDown() { - runBlocking { - server?.stop() - } - } - } - - @Test - fun testPublicEndpoint(vertx: Vertx, testContext: VertxTestContext) { - val client = WebClient.create(vertx) - client.get(port, "localhost", "/public") - .send() - .onSuccess { response -> - testContext.verify { - assertEquals(200, response.statusCode()) - assertEquals("Public Area", response.bodyAsString()) - testContext.completeNow() - } - } - .onFailure { err -> testContext.failNow(err) } - } - - @Test - fun testProtectedEndpointWithoutToken(vertx: Vertx, testContext: VertxTestContext) { - val client = WebClient.create(vertx) - client.get(port, "localhost", "/protected") - .send() - .onSuccess { response -> - testContext.verify { - assertEquals(401, response.statusCode()) - testContext.completeNow() - } - } - .onFailure { err -> testContext.failNow(err) } - } - - @Test - fun testProtectedEndpointWithToken(vertx: Vertx, testContext: VertxTestContext) { - val client = WebClient.create(vertx) - - // 1. Login to get token - client.post(port, "localhost", "/login") - .send() - .onSuccess { loginResponse -> - val token = loginResponse.bodyAsString() - - // 2. Access protected resource - client.get(port, "localhost", "/protected") - .putHeader("Authorization", "Bearer $token") - .send() - .onSuccess { response -> - testContext.verify { - assertEquals(200, response.statusCode()) - assertEquals("Hello testuser", response.bodyAsString()) - testContext.completeNow() - } - } - .onFailure { err -> testContext.failNow(err) } - } - .onFailure { err -> testContext.failNow(err) } - } - - @Test - fun testRbacAccess(vertx: Vertx, testContext: VertxTestContext) { - val client = WebClient.create(vertx) - - // 1. Login as user - client.post(port, "localhost", "/login") - .send() - .onSuccess { loginResponse -> - val userToken = loginResponse.bodyAsString() - - // 2. Try to access admin area (should fail) - client.get(port, "localhost", "/rbac/admin") - .putHeader("Authorization", "Bearer $userToken") - .send() - .onSuccess { response -> - testContext.verify { - assertEquals(403, response.statusCode()) - - // 3. Login as admin - client.post(port, "localhost", "/login-admin") - .send() - .onSuccess { adminLoginResponse -> - val adminToken = adminLoginResponse.bodyAsString() - - // 4. Access admin area (should hello) - client.get(port, "localhost", "/rbac/admin") - .putHeader("Authorization", "Bearer $adminToken") - .send() - .onSuccess { adminResponse -> - testContext.verify { - assertEquals(200, adminResponse.statusCode()) - assertEquals("Admin Area", adminResponse.bodyAsString()) - testContext.completeNow() - } - } - .onFailure { err -> testContext.failNow(err) } - } - .onFailure { err -> testContext.failNow(err) } - } - } - .onFailure { err -> testContext.failNow(err) } - } - .onFailure { err -> testContext.failNow(err) } - } -} diff --git a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/proxy/ProxyMiddlewareTest.kt b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/proxy/ProxyMiddlewareTest.kt index a8d03c3..9bdbd51 100644 --- a/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/proxy/ProxyMiddlewareTest.kt +++ b/example-app/src/jvmTest/kotlin/codes/yousef/aether/example/proxy/ProxyMiddlewareTest.kt @@ -36,17 +36,17 @@ class ProxyMiddlewareTest { .connectTimeout(Duration.ofSeconds(10)) .build() - private val proxyPort = 8085 - private val upstreamPort = 8086 - private val proxyUrl = "http://localhost:$proxyPort" - private val upstreamUrl = "http://localhost:$upstreamPort" + private var proxyPort: Int = 0 + private var upstreamPort: Int = 0 + private val proxyUrl: String get() = "http://localhost:$proxyPort" + private val upstreamUrl: String get() = "http://localhost:$upstreamPort" @BeforeAll fun setup() = runBlocking(AetherDispatcher.dispatcher) { vertx = Vertx.vertx() // Start mock upstream - upstreamServer = vertx.createHttpServer(HttpServerOptions().setPort(upstreamPort)) + upstreamServer = vertx.createHttpServer(HttpServerOptions().setPort(0)) .requestHandler { request -> val response = request.response() val path = request.path() @@ -85,6 +85,7 @@ class ProxyMiddlewareTest { } .listen() .coAwait() + upstreamPort = upstreamServer.actualPort() // Start proxy with middleware val pipeline = Pipeline().apply { @@ -126,11 +127,12 @@ class ProxyMiddlewareTest { } } - val config = VertxServerConfig(port = proxyPort) + val config = VertxServerConfig(port = 0) proxyServer = VertxServer(config, pipeline) { exchange -> exchange.notFound("Not Found - No matching proxy rule") } proxyServer.start() + proxyPort = proxyServer.actualPort delay(500) } diff --git a/example-app/src/wasmJsMain/kotlin/codes/yousef/aether/example/BrowserIdentityExample.kt b/example-app/src/wasmJsMain/kotlin/codes/yousef/aether/example/BrowserIdentityExample.kt new file mode 100644 index 0000000..3526eb7 --- /dev/null +++ b/example-app/src/wasmJsMain/kotlin/codes/yousef/aether/example/BrowserIdentityExample.kt @@ -0,0 +1,1078 @@ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + +package codes.yousef.aether.example + +import codes.yousef.aether.auth.ApproveDeviceGrantRequest +import codes.yousef.aether.auth.AdministrativeRecoveryIssueRequest +import codes.yousef.aether.auth.AdministrativeRecoveryTicketView +import codes.yousef.aether.auth.AuthenticationAssurance +import codes.yousef.aether.auth.BootstrapIdentityRequest +import codes.yousef.aether.auth.BootstrapIdentityResponse +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.ChallengeId +import codes.yousef.aether.auth.CreateInvitationRequest +import codes.yousef.aether.auth.CreateServiceCredentialRequest +import codes.yousef.aether.auth.CreateServiceIdentityRequest +import codes.yousef.aether.auth.CredentialId +import codes.yousef.aether.auth.CredentialState +import codes.yousef.aether.auth.DenyDeviceGrantRequest +import codes.yousef.aether.auth.DeviceGrantView +import codes.yousef.aether.auth.EmailAddress +import codes.yousef.aether.auth.IdentityErrorCode +import codes.yousef.aether.auth.IdentityErrorEnvelope +import codes.yousef.aether.auth.IdentityMeResponse +import codes.yousef.aether.auth.IdentitySessionCreatedResponse +import codes.yousef.aether.auth.IdentitySessionView +import codes.yousef.aether.auth.InspectDeviceGrantRequest +import codes.yousef.aether.auth.InvitationView +import codes.yousef.aether.auth.InvitationId +import codes.yousef.aether.auth.IssuedInvitationResponse +import codes.yousef.aether.auth.IssuedServiceCredentialResponse +import codes.yousef.aether.auth.IssuedServiceIdentityResponse +import codes.yousef.aether.auth.Membership +import codes.yousef.aether.auth.MembershipId +import codes.yousef.aether.auth.OrganizationAccessView +import codes.yousef.aether.auth.OrganizationId +import codes.yousef.aether.auth.OrganizationRole +import codes.yousef.aether.auth.PasskeyAuthenticationFinishRequest +import codes.yousef.aether.auth.PasskeyRegistrationFinishRequest +import codes.yousef.aether.auth.PasskeyRegistrationResponse +import codes.yousef.aether.auth.PasskeyView +import codes.yousef.aether.auth.RecoveryCodeUseRequest +import codes.yousef.aether.auth.RecoveryCodesResponse +import codes.yousef.aether.auth.RenamePasskeyRequest +import codes.yousef.aether.auth.ReplaceRecoveryCodesRequest +import codes.yousef.aether.auth.RotateServiceCredentialRequest +import codes.yousef.aether.auth.ServiceCredentialView +import codes.yousef.aether.auth.ServiceCredentialId +import codes.yousef.aether.auth.ServiceIdentity +import codes.yousef.aether.auth.ServiceIdentityId +import codes.yousef.aether.auth.SessionState +import codes.yousef.aether.auth.SessionId +import codes.yousef.aether.auth.UpdateMembershipRoleRequest +import codes.yousef.aether.auth.UserId +import codes.yousef.aether.auth.summon.AdministrativeRecoveryTicketUiModel +import codes.yousef.aether.auth.summon.AdministrativeRecoveryUiState +import codes.yousef.aether.auth.summon.CapabilityOptionUiModel +import codes.yousef.aether.auth.summon.DeviceApprovalUiState +import codes.yousef.aether.auth.summon.IdentityUiAction +import codes.yousef.aether.auth.summon.IdentityUiActionKind +import codes.yousef.aether.auth.summon.IdentityUiDispatcher +import codes.yousef.aether.auth.summon.IdentityUiFeedback +import codes.yousef.aether.auth.summon.IdentityUiFeedbackSeverity +import codes.yousef.aether.auth.summon.IdentityUiState +import codes.yousef.aether.auth.summon.NavigatorCredentialsPasskeyClient +import codes.yousef.aether.auth.summon.OneTimeIdentitySecretKind +import codes.yousef.aether.auth.summon.OneTimeIdentitySecretUiState +import codes.yousef.aether.auth.summon.OrganizationManagementUiState +import codes.yousef.aether.auth.summon.OrganizationUiModel +import codes.yousef.aether.auth.summon.PasskeyAuthenticationPurpose +import codes.yousef.aether.auth.summon.PasskeyCeremonyClient +import codes.yousef.aether.auth.summon.PasskeyCeremonyGateway +import codes.yousef.aether.auth.summon.PasskeyUiModel +import codes.yousef.aether.auth.summon.RecoveryCodesUiState +import codes.yousef.aether.auth.summon.RegistrationUiState +import codes.yousef.aether.auth.summon.InvitationDraftUiState +import codes.yousef.aether.auth.summon.InvitationUiModel +import codes.yousef.aether.auth.summon.MembershipUiModel +import codes.yousef.aether.auth.summon.ServiceCredentialUiModel +import codes.yousef.aether.auth.summon.ServiceIdentityDraftUiState +import codes.yousef.aether.auth.summon.ServiceIdentityUiModel +import codes.yousef.aether.auth.summon.SessionUiModel +import codes.yousef.aether.auth.summon.StepUpUiState +import codes.yousef.aether.auth.summon.hydrateIdentityUi +import codes.yousef.aether.auth.summon.reduceIdentityUiState +import codes.yousef.aether.auth.webauthn.AuthenticationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.RegistrationPublicKeyCredentialDto +import codes.yousef.aether.auth.webauthn.WebAuthnAuthenticationStartResponse +import codes.yousef.aether.auth.webauthn.WebAuthnRegistrationStartResponse +import codes.yousef.summon.runtime.PlatformRenderer +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** Browser entry point for both JVM-rendered Summon shells. */ +fun main() { + val contract = IdentityExampleContract() + val gateway = BrowserIdentityGateway(contract) + if (browserElementExists(BOOTSTRAP_ROOT_ID)) { + runBootstrapUi(contract, gateway) + } else if (browserElementExists(RECOVERY_ROOT_ID)) { + runRecoveryUi(contract, gateway) + } else if (browserElementExists(IDENTITY_ROOT_ID)) { + runIdentityUi(gateway) + } +} + +private fun runRecoveryUi(contract: IdentityExampleContract, gateway: BrowserIdentityGateway) { + val scope = MainScope() + var state = RecoveryIdentityUiState() + lateinit var dispatcher: RecoveryIdentityUiDispatcher + + fun render() = PlatformRenderer().hydrateComposableRoot(SUMMON_HYDRATION_ROOT_ID) { + RecoveryIdentityUi(state, dispatcher) + } + + dispatcher = RecoveryIdentityUiDispatcher { action -> + state = reduceRecoveryIdentityUiState(state, action) + if (action != RecoveryIdentityUiAction.Submit) { + render() + return@RecoveryIdentityUiDispatcher + } + val request = state.toRequest() ?: run { + render() + return@RecoveryIdentityUiDispatcher + } + state = state.copy(busy = true, feedback = null, failed = false) + render() + scope.launch { + try { + gateway.recover(request) + state = state.copy( + code = RecoveryCodeInput.Empty, + busy = false, + feedback = "Recovery accepted. Continuing to restricted passkey enrollment.", + failed = false + ) + render() + browserRedirect(contract.identityUi) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + state = state.copy( + busy = false, + feedback = "The recovery code is invalid, used, or expired.", + failed = true + ) + render() + } + } + } + render() +} + +private fun runBootstrapUi(contract: IdentityExampleContract, gateway: BrowserIdentityGateway) { + val scope = MainScope() + var state = BootstrapIdentityUiState() + lateinit var dispatcher: BootstrapIdentityUiDispatcher + + fun render() = PlatformRenderer().hydrateComposableRoot(SUMMON_HYDRATION_ROOT_ID) { + BootstrapIdentityUi(state, dispatcher) + } + + dispatcher = BootstrapIdentityUiDispatcher { action -> + state = reduceBootstrapIdentityUiState(state, action) + if (action != BootstrapIdentityUiAction.Submit) { + render() + return@BootstrapIdentityUiDispatcher + } + val request = state.toRequest() ?: run { + render() + return@BootstrapIdentityUiDispatcher + } + state = state.copy(busy = true, feedback = null, failed = false) + render() + scope.launch { + try { + gateway.bootstrap(request) + state = state.copy( + secret = BootstrapSecretInput.Empty, + busy = false, + feedback = "Owner created. Continuing to passkey enrollment.", + failed = false + ) + render() + browserRedirect(contract.identityUi) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + state = state.copy( + busy = false, + feedback = "Bootstrap failed. Verify the one-time secret and input values.", + failed = true + ) + render() + } + } + } + render() +} + +private fun runIdentityUi(gateway: BrowserIdentityGateway) { + val client = IdentityExampleClient( + passkeys = PasskeyCeremonyClient(gateway, NavigatorCredentialsPasskeyClient()), + api = gateway + ) + val scope = MainScope() + var state = restrictedEnrollmentUiState() + + lateinit var dispatcher: IdentityUiDispatcher + fun render() = hydrateIdentityUi(SUMMON_HYDRATION_ROOT_ID, state, dispatcher) + fun runNetwork( + kind: IdentityUiActionKind, + signsOut: Boolean = false, + operation: suspend () -> Unit + ) { + if (state.busyAction != null) return + state = state.copy(busyAction = kind, feedback = null) + render() + scope.launch { + state = try { + operation() + if (signsOut) { + IdentityUiState( + feedback = IdentityUiFeedback("Signed out. Use a passkey to sign in again.") + ) + } else { + val loaded = gateway.loadIdentityUiState() + if (loaded == null) { + clearBrowserCsrfToken() + IdentityUiState( + feedback = IdentityUiFeedback( + "The operation ended this session. Use a passkey to sign in again." + ) + ) + } else { + val message = when { + loaded.recoveryCodes is RecoveryCodesUiState.VisibleOnce -> + "Recovery codes replaced. Save these ten codes now." + loaded.oneTimeSecret is OneTimeIdentitySecretUiState.VisibleOnce -> + "A one-time value was issued. Save it before dismissing it." + else -> "Identity operation completed." + } + loaded.copy( + busyAction = null, + feedback = IdentityUiFeedback(message) + ) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + val code = (failure as? BrowserIdentityApiException)?.code + if (code == IdentityErrorCode.AUTHENTICATION_REQUIRED || + code == IdentityErrorCode.SESSION_EXPIRED || + code == IdentityErrorCode.SESSION_REVOKED + ) { + clearBrowserCsrfToken() + IdentityUiState( + feedback = IdentityUiFeedback( + code.publicMessage, + IdentityUiFeedbackSeverity.ERROR + ) + ) + } else { + state.copy( + busyAction = null, + recoveryCodes = gateway.currentRecoveryCodes(), + oneTimeSecret = gateway.currentOneTimeSecret(), + stepUp = if (code == IdentityErrorCode.STEP_UP_REQUIRED) { + state.stepUp.copy(required = true, reason = code.publicMessage) + } else { + state.stepUp + }, + feedback = IdentityUiFeedback( + code?.publicMessage ?: "The identity operation could not be completed.", + IdentityUiFeedbackSeverity.ERROR + ) + ) + } + } + render() + } + } + + dispatcher = IdentityUiDispatcher { action -> + state = reduceIdentityUiState(state, action) + when (action) { + IdentityUiAction.RegisterPasskey -> runNetwork(IdentityUiActionKind.REGISTER_PASSKEY) { + client.registerPasskey(state.registration.passkeyName) + if (browserEnrollmentPending()) { + client.discoverableSignIn() + clearBrowserEnrollmentPending() + } + } + IdentityUiAction.DiscoverableSignIn -> runNetwork(IdentityUiActionKind.SIGN_IN) { + client.discoverableSignIn() + } + IdentityUiAction.StepUpWithPasskey -> runNetwork(IdentityUiActionKind.STEP_UP) { + client.stepUp() + } + IdentityUiAction.GenerateRecoveryCodes -> runNetwork(IdentityUiActionKind.GENERATE_RECOVERY_CODES) { + gateway.replaceRecoveryCodes() + } + IdentityUiAction.DismissRecoveryCodes -> { + gateway.dismissRecoveryCodes() + render() + } + IdentityUiAction.DismissOneTimeSecret -> { + gateway.dismissOneTimeSecret() + render() + } + is IdentityUiAction.RenamePasskey -> runNetwork(IdentityUiActionKind.RENAME_PASSKEY) { + val name = state.passkeys.first { it.id == action.credentialId }.renameDraft + gateway.renamePasskey(action.credentialId, name) + } + is IdentityUiAction.RevokePasskey -> runNetwork(IdentityUiActionKind.REVOKE_PASSKEY) { + gateway.revokePasskey(action.credentialId) + } + is IdentityUiAction.RevokeSession -> { + val current = state.sessions.firstOrNull { it.id == action.sessionId }?.current == true + runNetwork(IdentityUiActionKind.REVOKE_SESSION, signsOut = current) { + if (current) gateway.logout() else gateway.revokeSession(action.sessionId) + } + } + IdentityUiAction.RevokeOtherSessions -> runNetwork(IdentityUiActionKind.REVOKE_OTHER_SESSIONS) { + gateway.revokeOtherSessions() + } + IdentityUiAction.RevokeAllSessions -> runNetwork( + IdentityUiActionKind.REVOKE_ALL_SESSIONS, + signsOut = true + ) { + gateway.revokeAllSessions() + } + is IdentityUiAction.SelectOrganization -> runNetwork(IdentityUiActionKind.SELECT_ORGANIZATION) { + gateway.selectOrganization(action.organizationId) + } + is IdentityUiAction.ChangeMembershipRole -> runNetwork(IdentityUiActionKind.UPDATE_MEMBERSHIP) { + gateway.changeMembershipRole( + action.organizationId, + action.membershipId, + action.role + ) + } + is IdentityUiAction.RemoveMembership -> runNetwork(IdentityUiActionKind.REMOVE_MEMBERSHIP) { + gateway.removeMembership(action.organizationId, action.membershipId) + } + is IdentityUiAction.InviteMember -> runNetwork(IdentityUiActionKind.INVITE_MEMBER) { + val draft = state.organizationManagement.invitationDraft + gateway.inviteMember(action.organizationId, draft.email, draft.role) + } + is IdentityUiAction.RevokeInvitation -> runNetwork(IdentityUiActionKind.REVOKE_INVITATION) { + gateway.revokeInvitation(action.organizationId, action.invitationId) + } + is IdentityUiAction.CreateServiceIdentity -> runNetwork(IdentityUiActionKind.CREATE_SERVICE_IDENTITY) { + val draft = state.organizationManagement.serviceIdentityDraft + gateway.createServiceIdentity( + action.organizationId, + draft.name, + draft.description, + draft.selectedCapabilities + ) + } + is IdentityUiAction.CreateServiceCredential -> runNetwork(IdentityUiActionKind.CREATE_SERVICE_CREDENTIAL) { + val identity = state.organizationManagement.serviceIdentities.first { it.id == action.serviceIdentityId } + gateway.createServiceCredential(action.organizationId, identity.id, identity.capabilities) + } + is IdentityUiAction.RotateServiceCredential -> runNetwork(IdentityUiActionKind.ROTATE_SERVICE_CREDENTIAL) { + val identity = state.organizationManagement.serviceIdentities.first { + candidate -> candidate.credentials.any { it.id == action.credentialId } + } + gateway.rotateServiceCredential(action.organizationId, identity.id, action.credentialId) + } + is IdentityUiAction.RevokeServiceCredential -> runNetwork(IdentityUiActionKind.REVOKE_SERVICE_CREDENTIAL) { + val identity = state.organizationManagement.serviceIdentities.first { + candidate -> candidate.credentials.any { it.id == action.credentialId } + } + gateway.revokeServiceCredential(action.organizationId, identity.id, action.credentialId) + } + is IdentityUiAction.RevokeServiceIdentity -> runNetwork(IdentityUiActionKind.REVOKE_SERVICE_IDENTITY) { + gateway.revokeServiceIdentity(action.organizationId, action.serviceIdentityId) + } + IdentityUiAction.IssueAdministrativeRecovery -> runNetwork( + IdentityUiActionKind.ADMINISTRATIVE_RECOVERY + ) { + gateway.issueAdministrativeRecovery(state.administrativeRecovery.userQuery) + } + is IdentityUiAction.CancelAdministrativeRecovery -> runNetwork( + IdentityUiActionKind.ADMINISTRATIVE_RECOVERY + ) { + gateway.cancelAdministrativeRecovery(action.ticketId) + } + IdentityUiAction.ResolveDeviceAuthorization -> runNetwork(IdentityUiActionKind.RESOLVE_DEVICE) { + client.resolveDevice(state.deviceAuthorization.userCode) + } + is IdentityUiAction.ApproveDeviceAuthorization -> runNetwork(IdentityUiActionKind.APPROVE_DEVICE) { + client.approveDevice(action.userCode, action.organizationId, action.capabilities) + } + is IdentityUiAction.DenyDeviceAuthorization -> runNetwork(IdentityUiActionKind.DENY_DEVICE) { + gateway.denyDevice(action.userCode) + } + else -> render() + } + } + render() + scope.launch { + var stateChanged = false + try { + gateway.loadIdentityUiState()?.let { loaded -> + state = loaded + stateChanged = true + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + val code = (failure as? BrowserIdentityApiException)?.code + state = state.copy( + feedback = IdentityUiFeedback( + code?.publicMessage ?: "Identity data could not be loaded.", + IdentityUiFeedbackSeverity.ERROR + ) + ) + stateChanged = true + } + // A normal unauthenticated probe returns null. Re-rendering an identical tree here would + // replace focused elements while a visitor is already typing or tabbing through sign-in. + if (stateChanged) render() + } +} + +private class BrowserIdentityGateway( + private val contract: IdentityExampleContract, + private val json: Json = Json { + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = false + } +) : PasskeyCeremonyGateway, IdentityExampleApi { + private var pendingRegistrationName: String? = null + private var pendingRecoveryCodes: RecoveryCodesUiState = RecoveryCodesUiState.Hidden + private var pendingOneTimeSecret: OneTimeIdentitySecretUiState = OneTimeIdentitySecretUiState.Hidden + private var pendingAdministrativeRecovery: AdministrativeRecoveryTicketView? = null + private var recoveryGeneration: Long? = null + private var selectedOrganizationId: OrganizationId? = null + private var publicClientConfig: IdentityExampleContract? = null + private var pendingDeviceUserCode: String? = null + private var pendingDeviceGrant: DeviceGrantView? = null + + suspend fun bootstrap(request: BootstrapIdentityRequest): BootstrapIdentityResponse { + val response = post(contract.bootstrap, request) + storeBrowserCsrfToken(response.csrfToken) + markBrowserEnrollmentPending("bootstrap") + return response + } + + suspend fun recover(request: RecoveryCodeUseRequest): IdentitySessionCreatedResponse { + val response = post( + contract.recoveryCodeUse, + request + ) + storeBrowserCsrfToken(response.csrfToken) + markBrowserEnrollmentPending("recovery") + return response + } + + suspend fun replaceRecoveryCodes() { + val response = post( + contract.recoveryCodesReplace, + ReplaceRecoveryCodesRequest(expectedGeneration = recoveryGeneration) + ) + recoveryGeneration = response.generation + pendingRecoveryCodes = RecoveryCodesUiState.VisibleOnce(response.codes) + } + + fun dismissRecoveryCodes() { + pendingRecoveryCodes = RecoveryCodesUiState.Hidden + } + + fun currentRecoveryCodes(): RecoveryCodesUiState = pendingRecoveryCodes + + fun dismissOneTimeSecret() { + pendingOneTimeSecret = OneTimeIdentitySecretUiState.Hidden + } + + fun currentOneTimeSecret(): OneTimeIdentitySecretUiState = pendingOneTimeSecret + + suspend fun loadIdentityUiState(): IdentityUiState? { + val clientConfig = loadPublicClientConfig() + val me = try { + get(contract.me) + } catch (failure: BrowserIdentityApiException) { + if (failure.code == IdentityErrorCode.AUTHENTICATION_REQUIRED || + failure.code == IdentityErrorCode.SESSION_EXPIRED || + failure.code == IdentityErrorCode.SESSION_REVOKED + ) return null + throw failure + } + val passkeys = get>(contract.passkeys).map(PasskeyView::toUiModel) + val sessionViews = get>(contract.sessions) + .filter { it.state == SessionState.ACTIVE } + val sessions = sessionViews.map(IdentitySessionView::toUiModel) + val currentSession = sessionViews.firstOrNull { it.current } + val organizations = listOrganizations().mapNotNull(OrganizationAccessView::toUiModelOrNull) + val selected = organizations.firstOrNull { it.id == selectedOrganizationId } ?: organizations.firstOrNull() + selectedOrganizationId = selected?.id + val organizationManagement = if (selected == null) { + OrganizationManagementUiState(organizations = organizations) + } else { + loadOrganizationManagement( + organizations, + selected, + me.userId, + clientConfig.serviceCredentialCapabilities + ) + } + val deviceApproval = loadDeviceApproval(organizations) + return IdentityUiState( + signedInDisplayName = me.displayName, + passkeys = passkeys, + sessions = sessions, + recoveryCodes = pendingRecoveryCodes, + oneTimeSecret = pendingOneTimeSecret, + administrativeRecovery = administrativeRecoveryUiState(clientConfig.administrativeRecoveryEnabled), + stepUp = currentSession.toStepUpUiState(), + organizationManagement = organizationManagement, + deviceApproval = deviceApproval + ) + } + + override suspend fun startRegistration(passkeyName: String): WebAuthnRegistrationStartResponse { + pendingRegistrationName = passkeyName + return try { + postWithoutBody(contract.registrationStart) + } catch (failure: Throwable) { + pendingRegistrationName = null + throw failure + } + } + + override suspend fun finishRegistration( + ceremonyId: ChallengeId, + credential: RegistrationPublicKeyCredentialDto + ) { + val name = pendingRegistrationName ?: throw BrowserIdentityApiException() + pendingRegistrationName = null + val response = post( + contract.registrationFinish, + PasskeyRegistrationFinishRequest( + ceremonyId = ceremonyId, + credentialName = name, + credential = credential + ) + ) + response.replacementRecoveryCodes?.let { codes -> + pendingRecoveryCodes = RecoveryCodesUiState.VisibleOnce(codes) + recoveryGeneration = when (browserEnrollmentKind()) { + "bootstrap" -> 0L + "recovery" -> (recoveryGeneration ?: 0L) + 1L + else -> recoveryGeneration + } + clearBrowserCsrfToken() + } + } + + override suspend fun startAuthentication( + purpose: PasskeyAuthenticationPurpose + ): WebAuthnAuthenticationStartResponse = postWithoutBody( + when (purpose) { + PasskeyAuthenticationPurpose.DISCOVERABLE_SIGN_IN -> contract.authenticationStart + PasskeyAuthenticationPurpose.STEP_UP -> contract.stepUpStart + } + ) + + override suspend fun finishAuthentication( + ceremonyId: ChallengeId, + purpose: PasskeyAuthenticationPurpose, + credential: AuthenticationPublicKeyCredentialDto + ) { + val path = when (purpose) { + PasskeyAuthenticationPurpose.DISCOVERABLE_SIGN_IN -> contract.authenticationFinish + PasskeyAuthenticationPurpose.STEP_UP -> contract.stepUpFinish + } + val response = post( + path, + PasskeyAuthenticationFinishRequest(ceremonyId = ceremonyId, credential = credential) + ) + storeBrowserCsrfToken(response.csrfToken) + } + + suspend fun renamePasskey(credentialId: CredentialId, name: String) { + patchWithoutResponse(contract.passkey(credentialId), RenamePasskeyRequest(name)) + } + + suspend fun revokePasskey(credentialId: CredentialId) { + delete(contract.passkey(credentialId)) + } + + suspend fun logout() { + postWithoutResponse(contract.logout) + clearBrowserCsrfToken() + } + + suspend fun revokeSession(sessionId: SessionId) { + delete(contract.session(sessionId)) + } + + suspend fun revokeOtherSessions() { + postWithoutResponse(contract.revokeOtherSessions) + } + + suspend fun revokeAllSessions() { + postWithoutResponse(contract.revokeAllSessions) + clearBrowserCsrfToken() + } + + fun selectOrganization(organizationId: OrganizationId) { + selectedOrganizationId = organizationId + } + + suspend fun changeMembershipRole( + organizationId: OrganizationId, + membershipId: MembershipId, + role: OrganizationRole + ) { + patchWithoutResponse( + contract.membership(organizationId, membershipId), + UpdateMembershipRoleRequest(role) + ) + } + + suspend fun removeMembership(organizationId: OrganizationId, membershipId: MembershipId) { + delete(contract.membership(organizationId, membershipId)) + } + + suspend fun inviteMember(organizationId: OrganizationId, email: String, role: OrganizationRole) { + val response = post( + contract.invitations(organizationId), + CreateInvitationRequest(EmailAddress(email.trim()), role) + ) + pendingOneTimeSecret = OneTimeIdentitySecretUiState.VisibleOnce( + kind = OneTimeIdentitySecretKind.INVITATION_TOKEN, + label = "Invitation for ${response.invitation.email.value}", + secret = response.token + ) + } + + suspend fun revokeInvitation(organizationId: OrganizationId, invitationId: InvitationId) { + delete(contract.invitation(organizationId, invitationId)) + } + + suspend fun createServiceIdentity( + organizationId: OrganizationId, + name: String, + description: String, + capabilities: Set + ) { + val response = post( + contract.serviceIdentities(organizationId), + CreateServiceIdentityRequest( + name = name, + description = description.takeIf(String::isNotBlank), + capabilities = capabilities + ) + ) + pendingOneTimeSecret = OneTimeIdentitySecretUiState.VisibleOnce( + kind = OneTimeIdentitySecretKind.SERVICE_CREDENTIAL, + label = "Credential for ${response.identity.name}", + secret = response.token + ) + } + + suspend fun createServiceCredential( + organizationId: OrganizationId, + serviceIdentityId: ServiceIdentityId, + capabilities: Set + ) { + val response = post( + contract.serviceCredentials(organizationId, serviceIdentityId), + CreateServiceCredentialRequest(capabilities) + ) + pendingOneTimeSecret = OneTimeIdentitySecretUiState.VisibleOnce( + kind = OneTimeIdentitySecretKind.SERVICE_CREDENTIAL, + label = "Credential ${response.credential.publicPrefix}", + secret = response.token + ) + } + + suspend fun rotateServiceCredential( + organizationId: OrganizationId, + serviceIdentityId: ServiceIdentityId, + credentialId: ServiceCredentialId + ) { + val response = post( + contract.rotateServiceCredential(organizationId, serviceIdentityId, credentialId), + RotateServiceCredentialRequest() + ) + pendingOneTimeSecret = OneTimeIdentitySecretUiState.VisibleOnce( + kind = OneTimeIdentitySecretKind.SERVICE_CREDENTIAL, + label = "Rotated credential ${response.credential.publicPrefix}", + secret = response.token + ) + } + + suspend fun revokeServiceCredential( + organizationId: OrganizationId, + serviceIdentityId: ServiceIdentityId, + credentialId: ServiceCredentialId + ) { + delete(contract.serviceCredential(organizationId, serviceIdentityId, credentialId)) + } + + suspend fun revokeServiceIdentity(organizationId: OrganizationId, serviceIdentityId: ServiceIdentityId) { + delete(contract.serviceIdentity(organizationId, serviceIdentityId)) + } + + suspend fun issueAdministrativeRecovery(userQuery: String) { + val userId = UserId.parseOrNull(userQuery.trim()) + ?: throw BrowserIdentityApiException(IdentityErrorCode.REQUEST_INVALID) + pendingAdministrativeRecovery = post( + contract.administrativeRecoveryTickets, + AdministrativeRecoveryIssueRequest(userId) + ) + } + + suspend fun cancelAdministrativeRecovery(ticketId: ChallengeId) { + delete(contract.administrativeRecoveryTicket(ticketId)) + pendingAdministrativeRecovery = null + } + + override suspend fun listOrganizations(): List = get(contract.organizations) + + override suspend fun inspectDevice(request: InspectDeviceGrantRequest): DeviceGrantView { + val grant = post(contract.deviceVerification, request) + pendingDeviceUserCode = request.userCode + pendingDeviceGrant = grant + return grant + } + + override suspend fun approveDevice(request: ApproveDeviceGrantRequest) { + postWithoutResponse(contract.deviceApproval, request) + clearPendingDeviceGrant() + } + + suspend fun denyDevice(userCode: String) { + postWithoutResponse(contract.deviceDenial, DenyDeviceGrantRequest(userCode)) + clearPendingDeviceGrant() + } + + private suspend fun loadOrganizationManagement( + organizations: List, + selected: OrganizationUiModel, + currentUserId: UserId, + serviceCredentialCapabilities: Set + ): OrganizationManagementUiState { + val canInvite = selected.role.grants(Capability.MEMBERSHIP_INVITE) + val canManageServices = selected.role.grants(Capability.SERVICE_IDENTITY_MANAGE) + val memberships = get>(contract.memberships(selected.id)).map { membership -> + membership.toUiModel(selected.role, currentUserId) + } + val invitations = get>(contract.invitations(selected.id)).map { invitation -> + invitation.toUiModel(selected.role, canInvite) + } + val serviceIdentities = if (selected.role.grants(Capability.SERVICE_IDENTITY_READ)) { + get>(contract.serviceIdentities(selected.id)).map { identity -> + val credentials = get>( + contract.serviceCredentials(selected.id, identity.id) + ) + identity.toUiModel(credentials, canManageServices) + } + } else { + emptyList() + } + val invitationRoles = if (selected.role == OrganizationRole.OWNER) { + OrganizationRole.entries.toSet() + } else { + OrganizationRole.entries.filterNot { it == OrganizationRole.OWNER }.toSet() + } + return OrganizationManagementUiState( + organizations = organizations, + selectedOrganizationId = selected.id, + memberships = memberships, + invitationDraft = InvitationDraftUiState(allowedRoles = invitationRoles), + invitations = invitations, + serviceIdentityDraft = ServiceIdentityDraftUiState( + capabilityOptions = if (canManageServices) { + serviceCredentialCapabilities.toCapabilityOptions() + } else { + emptyList() + } + ), + serviceIdentities = serviceIdentities, + canInviteMembers = canInvite, + canManageServiceIdentities = canManageServices + ) + } + + private fun administrativeRecoveryUiState(enabled: Boolean): AdministrativeRecoveryUiState { + val ticket = pendingAdministrativeRecovery + return AdministrativeRecoveryUiState( + enabled = enabled, + outstandingTicket = ticket?.let { + AdministrativeRecoveryTicketUiModel(it.id, it.userId, it.expiresAt.toString()) + }, + deliveryStatus = ticket?.let { "The configured notification sink accepted the enrollment link." } + ) + } + + private suspend fun loadPublicClientConfig(): IdentityExampleContract { + publicClientConfig?.let { return it } + return try { + get(contract.clientConfig).also { publicClientConfig = it } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + contract + } + } + + private fun loadDeviceApproval( + organizations: List + ): DeviceApprovalUiState? { + val userCode = pendingDeviceUserCode ?: return null + val grant = pendingDeviceGrant ?: return null + val requested = grant.requestedCapabilities + val allowedByOrganization = organizations.mapNotNull { organization -> + val allowed = requested.intersect(exampleCapabilities(organization.role)) + if (allowed.isEmpty()) null else organization to allowed + } + if (allowedByOrganization.isEmpty()) return null + val eligibleOrganizations = allowedByOrganization.map { it.first } + return DeviceApprovalUiState( + userCode = userCode, + clientName = grant.clientName, + expiresAt = grant.expiresAt.toString(), + organizations = eligibleOrganizations, + approvableCapabilitiesByOrganization = allowedByOrganization.associate { it.first.id to it.second }, + selectedOrganizationId = eligibleOrganizations.singleOrNull()?.id, + capabilityOptions = requested.sortedBy { it.wireName }.map { capability -> + CapabilityOptionUiModel(capability, capability.wireName) + } + ) + } + + private fun clearPendingDeviceGrant() { + pendingDeviceUserCode = null + pendingDeviceGrant = null + } + + private suspend inline fun get(path: String): T = + json.decodeFromString(request(path, "GET", null)) + + private suspend inline fun postWithoutBody(path: String): Response = + json.decodeFromString(request(path, "POST", null)) + + private suspend inline fun post(path: String, body: Request): Response = + json.decodeFromString(request(path, "POST", json.encodeToString(body))) + + private suspend inline fun postWithoutResponse(path: String, body: Request) { + request(path, "POST", json.encodeToString(body)) + } + + private suspend fun postWithoutResponse(path: String) { + request(path, "POST", null) + } + + private suspend inline fun patchWithoutResponse(path: String, body: Request) { + request(path, "PATCH", json.encodeToString(body)) + } + + private suspend fun delete(path: String) { + request(path, "DELETE", null) + } +} + +private fun OrganizationAccessView.toUiModelOrNull(): OrganizationUiModel? { + val parsedRole = OrganizationRole.entries.singleOrNull { it.wireName == role } ?: return null + return OrganizationUiModel(id = id, name = name, slug = slug, role = parsedRole) +} + +private fun PasskeyView.toUiModel(): PasskeyUiModel = PasskeyUiModel( + id = id, + name = name, + createdAt = createdAt.toString(), + lastUsedAt = lastUsedAt?.toString(), + backedUp = backedUp, + canRevoke = state == CredentialState.ACTIVE +) + +private fun IdentitySessionView.toUiModel(): SessionUiModel { + val deviceLabel = label?.takeIf(String::isNotBlank) + ?: platform?.takeIf(String::isNotBlank) + ?: "Unnamed device" + return SessionUiModel( + id = id, + deviceLabel = deviceLabel, + lastUsedAt = lastUsedAt.toString(), + expiresAt = minOf(idleExpiresAt, absoluteExpiresAt).toString(), + current = current, + recentPasskey = assurance == AuthenticationAssurance.PASSKEY || + assurance == AuthenticationAssurance.STEP_UP + ) +} + +private fun IdentitySessionView?.toStepUpUiState(): StepUpUiState = if ( + this != null && (assurance == AuthenticationAssurance.PASSKEY || assurance == AuthenticationAssurance.STEP_UP) +) { + StepUpUiState(satisfiedAt = authenticatedAt.toString()) +} else { + StepUpUiState() +} + +private fun Membership.toUiModel(actorRole: OrganizationRole, currentUserId: UserId): MembershipUiModel { + val actorCanManage = actorRole.grants(Capability.MEMBERSHIP_UPDATE) + val mayManageTarget = actorCanManage && (actorRole == OrganizationRole.OWNER || role != OrganizationRole.OWNER) + val allowedRoles = when { + actorRole == OrganizationRole.OWNER -> OrganizationRole.entries.toSet() + mayManageTarget -> OrganizationRole.entries.filterNot { it == OrganizationRole.OWNER }.toSet() + else -> setOf(role) + } + val stableLabel = if (userId == currentUserId) "You (${userId.value})" else "User ${userId.value}" + return MembershipUiModel( + id = id, + organizationId = organizationId, + userId = userId, + displayName = stableLabel, + role = role, + state = state, + allowedRoles = allowedRoles, + canChangeRole = mayManageTarget && state == codes.yousef.aether.auth.MembershipState.ACTIVE, + canRemove = actorRole.grants(Capability.MEMBERSHIP_REMOVE) && + (actorRole == OrganizationRole.OWNER || role != OrganizationRole.OWNER) && + state == codes.yousef.aether.auth.MembershipState.ACTIVE + ) +} + +private fun InvitationView.toUiModel( + actorRole: OrganizationRole, + canInvite: Boolean +): InvitationUiModel = InvitationUiModel( + id = id, + organizationId = organizationId, + email = email.value, + role = role, + state = state, + expiresAt = expiresAt.toString(), + canRevoke = canInvite && state == codes.yousef.aether.auth.InvitationState.PENDING && + (actorRole == OrganizationRole.OWNER || role != OrganizationRole.OWNER) +) + +private fun ServiceIdentity.toUiModel( + credentialViews: List, + canManageServices: Boolean +): ServiceIdentityUiModel = ServiceIdentityUiModel( + id = id, + organizationId = organizationId, + name = name, + description = description, + capabilities = capabilities, + state = state, + credentials = credentialViews.map(ServiceCredentialView::toUiModel), + canManage = canManageServices +) + +private fun ServiceCredentialView.toUiModel(): ServiceCredentialUiModel = ServiceCredentialUiModel( + id = id, + publicPrefix = publicPrefix, + capabilities = capabilities, + state = state, + expiresAt = expiresAt?.toString() +) + +private fun Set.toCapabilityOptions(): List = sortedBy { it.wireName }.map { capability -> + CapabilityOptionUiModel( + capability = capability, + label = capability.wireName.split('.', '_').joinToString(" ") { word -> + word.replaceFirstChar(Char::uppercase) + } + ) +} + +private fun exampleCapabilities(role: OrganizationRole): Set = buildSet { + addAll(role.capabilities) + if (role == OrganizationRole.OWNER || role == OrganizationRole.ADMIN || role == OrganizationRole.PUBLISHER) { + add(Capability("package.publish")) + } +} + +private fun restrictedEnrollmentUiState(): IdentityUiState { + if (!browserEnrollmentPending()) return IdentityUiState() + return IdentityUiState( + registration = RegistrationUiState(signInEnabled = false), + feedback = IdentityUiFeedback( + "Restricted recovery session: enroll a passkey to continue. Other identity actions are unavailable." + ) + ) +} + +private class BrowserIdentityApiException( + val code: IdentityErrorCode? = null +) : IllegalStateException(code?.publicMessage ?: "Identity request failed") + +private val browserIdentityErrorJson = Json { ignoreUnknownKeys = true } + +private suspend fun request(path: String, method: String, body: String?): String = + suspendCoroutine { continuation -> + browserIdentityRequest(path, method, body) { status, response, failed -> + if (!failed && status in 200..299 && response != null) { + continuation.resume(response) + } else { + val code = response?.let { body -> + runCatching { + browserIdentityErrorJson.decodeFromString(body).error.code + }.getOrNull() + } + continuation.resumeWithException(BrowserIdentityApiException(code)) + } + } + } + +/** Same-origin fetch keeps opaque session cookies out of wasm memory. */ +@JsFun(""" +(path, method, body, callback) => { + try { + const headers = {"Accept": "application/json"}; + if (body !== null) headers["Content-Type"] = "application/json"; + const csrf = globalThis.sessionStorage && globalThis.sessionStorage.getItem("aether.identity.csrf.v1"); + if (csrf) headers["X-CSRF-Token"] = csrf; + globalThis.fetch(path, { + method, + headers, + body: body === null ? undefined : body, + credentials: "same-origin", + redirect: "error" + }).then(async response => { + const text = await response.text(); + callback(response.status, text, false); + }).catch(() => callback(0, null, true)); + } catch (_) { + callback(0, null, true); + } +} +""") +private external fun browserIdentityRequest( + path: String, + method: String, + body: String?, + callback: (Int, String?, Boolean) -> Unit +) + +@JsFun("id => globalThis.document && globalThis.document.getElementById(id) !== null") +private external fun browserElementExists(id: String): Boolean + +@JsFun("value => globalThis.sessionStorage.setItem('aether.identity.csrf.v1', value)") +private external fun storeBrowserCsrfToken(value: String) + +@JsFun("() => globalThis.sessionStorage.removeItem('aether.identity.csrf.v1')") +private external fun clearBrowserCsrfToken() + +@JsFun("kind => globalThis.sessionStorage.setItem('aether.identity.enrollment.v1', kind)") +private external fun markBrowserEnrollmentPending(kind: String) + +@JsFun("() => globalThis.sessionStorage.getItem('aether.identity.enrollment.v1')") +private external fun browserEnrollmentKind(): String? + +private fun browserEnrollmentPending(): Boolean = browserEnrollmentKind() != null + +@JsFun("() => globalThis.sessionStorage.removeItem('aether.identity.enrollment.v1')") +private external fun clearBrowserEnrollmentPending() + +@JsFun("path => globalThis.location.assign(path)") +private external fun browserRedirect(path: String) + +private const val IDENTITY_ROOT_ID = "aether-identity" +private const val SUMMON_HYDRATION_ROOT_ID = "summon-app" diff --git a/example-app/src/wasmJsTest/kotlin/codes/yousef/aether/example/IdentityExampleWasmContractTest.kt b/example-app/src/wasmJsTest/kotlin/codes/yousef/aether/example/IdentityExampleWasmContractTest.kt new file mode 100644 index 0000000..d975255 --- /dev/null +++ b/example-app/src/wasmJsTest/kotlin/codes/yousef/aether/example/IdentityExampleWasmContractTest.kt @@ -0,0 +1,42 @@ +package codes.yousef.aether.example + +import codes.yousef.aether.auth.Capability +import codes.yousef.aether.auth.OrganizationId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IdentityExampleWasmContractTest { + private val json = Json { encodeDefaults = true } + + @Test + fun `wasm client compiles against the explicit organization and device contract`() { + val contract = IdentityExampleContract() + val organizationId = OrganizationId("018f47d2-8d4d-7abc-8def-1234567890ae") + val encoded = json.encodeToString(contract) + + assertEquals( + "/identity/v1/organizations/${organizationId.value}/invitations", + contract.invitations(organizationId) + ) + assertTrue("/oauth/device_authorization" in encoded) + assertTrue("/oauth/token" in encoded) + assertTrue("/identity/v1/invitations/enroll" in encoded) + assertTrue("/identity/v1/recovery/codes/use" in encoded) + assertTrue("/identity/v1/recovery/codes/replace" in encoded) + assertTrue("/identity/v1/passkeys/step-up/start" in encoded) + assertTrue("/identity/v1/passkeys/step-up/finish" in encoded) + assertTrue("/identity/v1/passkeys" in encoded) + assertTrue("/identity/v1/sessions/revoke-others" in encoded) + assertTrue("/identity/v1/sessions/revoke-all" in encoded) + assertTrue("/identity/v1/logout" in encoded) + assertTrue("/identity/v1/recovery/admin/tickets" in encoded) + assertTrue("/identity/v1/device/approve" in encoded) + assertTrue("/identity/v1/device/deny" in encoded) + assertTrue(contract.serviceCredentialCapabilities.none { + it in Capability.IDENTITY_MANAGEMENT || it == Capability.ACCOUNT_RECOVERY_ADMIN + }) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7689bf1..b7ec03e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,25 +1,27 @@ [versions] -kotlin = "2.1.0" -coroutines = "1.9.0" -serialization = "1.7.3" -atomicfu = "0.26.0" +kotlin = "2.3.21" +coroutines = "1.10.2" +serialization = "1.9.0" +atomicfu = "0.30.0-beta" vertx = "4.5.11" slf4j = "2.0.16" hikari = "6.2.1" logback = "1.5.12" kotlin-wrappers = "1.0.0-pre.830" -kotlin-test = "2.1.0" +kotlin-test = "2.3.21" testcontainers = "1.20.4" mockk = "1.13.13" postgres = "42.7.4" -ksp = "2.1.0-1.0.29" -datetime = "0.6.2" +ksp = "2.3.9" +datetime = "0.7.1" java-jwt = "4.4.0" kotlinpoet = "1.18.1" -ksp-api = "2.1.0-1.0.29" +ksp-api = "2.3.9" netty = "4.1.115.Final" bcrypt = "0.10.2" wiremock = "3.9.2" +summon = "0.7.0.2" +xmlutil = "0.91.3" [libraries] # Kotlin Core @@ -60,6 +62,12 @@ testcontainers-postgresql = { module = "org.testcontainers:postgresql", version. mockk = { module = "io.mockk:mockk", version.ref = "mockk" } wiremock = { module = "org.wiremock:wiremock", version.ref = "wiremock" } +# Summon UI +summon = { module = "codes.yousef:summon", version.ref = "summon" } + +# Cross-platform XML parsing for the optional SAML adapter +xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" } + # DateTime kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 0000000..2aca1cf --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,5986 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock new file mode 100644 index 0000000..5f4567d --- /dev/null +++ b/kotlin-js-store/wasm/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== diff --git a/llms-full.txt b/llms-full.txt index 23d8915..b0ba9d7 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2,6 +2,9 @@ > A Django-like Kotlin Multiplatform framework for JVM (Vert.x + Virtual Threads) and Wasm (Cloudflare/Browser). +> Release status: `0.6.0.0` is unreleased and publish-blocked by the combined wasmWasi host, +> adversarial-review, and manual hardware-passkey gates. + ## Architecture Overview Aether is a "colorless" framework that abstracts away platform differences via expect/actual declarations, enabling developers to write application logic once and deploy anywhere. @@ -22,7 +25,8 @@ Aether is a "colorless" framework that abstracts away platform differences via e | aether-signals | Event system (preSave, postSave, etc.) | | aether-tasks | Background job queue with KSP code generation | | aether-channels | WebSocket pub/sub groups | -| aether-auth | Authentication providers (Basic, Bearer, JWT, API Key, Form) | +| aether-auth | Storage-neutral passkey-first identity engine | +| aether-auth-* | Optional PostgreSQL, Firestore, Summon, OIDC, SAML, and SCIM adapters | | aether-ui | Composable UI DSL, SSR + CBOR serialization | | aether-net | Transport abstraction (TCP, future protocols) | | aether-ksp | Database migration generation | @@ -75,9 +79,9 @@ kotlin { jvm() sourceSets { commonMain.dependencies { - implementation("codes.yousef.aether:aether-core:0.4.0") - implementation("codes.yousef.aether:aether-web:0.4.0") - implementation("codes.yousef.aether:aether-db:0.4.0") + implementation("codes.yousef.aether:aether-core:0.6.0.0") + implementation("codes.yousef.aether:aether-web:0.6.0.0") + implementation("codes.yousef.aether:aether-db:0.6.0.0") } } } @@ -286,29 +290,14 @@ exchange.render { } ``` -### Authentication +### Identity -```kotlin -val authConfig = AuthenticationConfig().apply { - providers["basic"] = BasicAuthProvider { credentials -> - if (validateUser(credentials.username, credentials.password)) { - AuthResult.Success(Principal.User(credentials.username, setOf("user"))) - } else { - AuthResult.Failure("Invalid credentials") - } - } - providers["jwt"] = JwtAuthProvider(jwtConfig) -} - -val pipeline = Pipeline().apply { - installAuthentication(authConfig) { - excludePaths.add("/public") - } - installAuthorization(AuthorizationConfig().apply { - rules["/admin"] = AuthorizationRule(requiredRoles = setOf("admin")) - }) -} -``` +Use `aether-auth` for passkey-authenticated people, organizations, recovery, CLI device +authorization, service identities, and optional enterprise federation/provisioning. Password +authentication, primary JWT fallback, legacy identity sessions, and global identity +groups/permissions are not supported. Generic `aether-core` authentication remains separate for +unrelated application protocols and must not be treated as Aether Identity authority. See +`docs/identity/README.md` and `docs/identity/deployment.md`. ## File Uploads @@ -398,9 +387,9 @@ val migration = migration("002", "Add users table") { ```bash ./gradlew build # Full build all targets ./gradlew :aether-core:jvmTest # Run single module's JVM tests -./gradlew :example-app:run # Run example app (requires PostgreSQL) -./gradlew :example-app:test # Integration tests with TestContainers -./gradlew check -x wasmJsBrowserTest -x :example-app:test # CI-style test run +./gradlew :example-app:run # Run the passkey identity reference app +./gradlew :example-app:allTests # Run the example target tests +./gradlew verifyExpectedSourceTasks check -x wasmJsBrowserTest # CI compile/test gate ./gradlew publishToMavenLocal # Publish to local Maven for testing ``` @@ -412,20 +401,10 @@ val migration = migration("002", "Add users table") { ## Production Deployment -### Docker - -```bash -docker build -t aether-app:latest -f docs/deployment/Dockerfile . -cd docs/deployment -docker compose up -d -``` - -### Kubernetes - -```bash -kubectl apply -f docs/deployment/kubernetes/ -kubectl -n aether get pods -``` +Aether Identity users must follow only `docs/identity/deployment.md`. The old repository-wide +Docker, Compose, Nginx, SQL, and Kubernetes samples were removed because they invoked nonexistent +distribution tasks and configured legacy JWT/session and raw-IP behavior. Unrelated `aether-core` +applications own their application image and orchestration model. ## Package Naming diff --git a/llms.txt b/llms.txt index 1147697..9b3a908 100644 --- a/llms.txt +++ b/llms.txt @@ -2,6 +2,9 @@ > A Django-like Kotlin Multiplatform framework for JVM (Vert.x + Virtual Threads) and Wasm (Cloudflare/Browser). +> Release status: `0.6.0.0` is unreleased and publish-blocked by the combined wasmWasi host, +> adversarial-review, and manual hardware-passkey gates. + ## Overview Aether is a "colorless" framework that abstracts platform differences via expect/actual declarations, enabling write-once-deploy-anywhere web applications. @@ -14,7 +17,7 @@ Aether is a "colorless" framework that abstracts platform differences via expect - **Active Record ORM**: Django-inspired models with QueryAST (not string SQL) - **Middleware Pipeline**: Composable request/response processing - **SSR + Hydration**: Server-side rendering with CBOR serialization -- **Authentication**: Basic, Bearer, JWT, API Key, Form providers +- **Identity**: Passkey-first people, organizations, opaque sessions, device flow, service identities, OIDC, SAML, and SCIM - **WebSocket Support**: Full-duplex with pub/sub channels - **gRPC Support**: DSL, adapters, and code-first proto generation @@ -25,7 +28,8 @@ Aether is a "colorless" framework that abstracts platform differences via expect | aether-core | Exchange, Pipeline, Dispatcher foundation | | aether-web | Radix tree router, path parameters | | aether-db | ORM, QueryAST, database drivers | -| aether-auth | Authentication providers | +| aether-auth | Storage-neutral passkey-first identity engine | +| aether-auth-* | Optional PostgreSQL, Firestore, Summon, OIDC, SAML, and SCIM adapters | | aether-ui | Composable UI DSL, SSR | | aether-grpc | gRPC with DSL and proto generation | | aether-channels | WebSocket pub/sub groups | @@ -66,5 +70,6 @@ AetherServer.start(port = 8080, pipeline = pipeline) ## Links - Full documentation: llms-full.txt +- Identity deployment: docs/identity/deployment.md - Repository: https://github.com/yousef-codes/aether - Package: codes.yousef.aether diff --git a/settings-gradle.lockfile b/settings-gradle.lockfile new file mode 100644 index 0000000..709a43f --- /dev/null +++ b/settings-gradle.lockfile @@ -0,0 +1,4 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +empty=incomingCatalogForLibs0 diff --git a/settings.gradle.kts b/settings.gradle.kts index f2f40f3..91114d0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,13 @@ include( ":aether-cli", ":aether-ksp", ":aether-auth", + ":aether-auth-testkit", + ":aether-auth-postgresql", + ":aether-auth-firestore", + ":aether-auth-summon", + ":aether-auth-oidc", + ":aether-auth-saml", + ":aether-auth-scim", ":aether-forms", ":aether-admin", ":aether-grpc", diff --git a/version.properties b/version.properties index ab9bdda..8cf5e0a 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1 @@ -VERSION=0.5.1.0 - +VERSION=0.6.0.0