diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index a45773a09..374033393 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -41,6 +41,8 @@ jobs: - name: Build with Gradle id: gradle_build continue-on-error: true + env: + TESTCONTAINERS_RYUK_DISABLED: true run: ./gradlew build - name: Publish to Maven Central with JReleaser diff --git a/.gitignore b/.gitignore index 9cc0ae942..31e24e43e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# Local frontend runtime config overrides (not for source control) +structures-frontend-next/public/app-config.override.json +structures-frontend-next/public/app-config.json.local +structures-frontend-next/public/config/app-config.override.json +structures-frontend-next/public/config/app-config.json.local +# Local service config overrides (not for source control) +structures-server/src/main/resources/application-local.yml + # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 .gradle @@ -78,4 +86,10 @@ structures-js/structures-e2e/test/services/generated/ structures-sql/allure-results/ +structures-auth/allure-results/ + +structures-server/allure-results/ + +structures-server/src/main/resources/webroot/ + structures-server/src/main/resources/webroot2/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7ded0a1a5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + "recommendations": [ + "vscjava.vscode-java-pack", + "vscjava.vscode-gradle", + "redhat.java", + "vscjava.vscode-java-debug", + "vscjava.vscode-java-test", + "vscjava.vscode-maven", + "vscjava.vscode-java-dependency", + "vscjava.vscode-spring-initializr", + "vscjava.vscode-spring-boot", + "vscjava.vscode-lombok" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 759828456..96b77671a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,20 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "command": "pnpm dev", + "name": "Start Structures Frontend", + "request": "launch", + "type": "node-terminal", + "cwd": "${workspaceFolder}/structures-frontend-next" + }, + { + "name": "Launch Chrome Debugger for UI", + "request": "launch", + "type": "chrome", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/structures-frontend-next" + }, { "type": "java", "name": "StructuresServerApplication", @@ -12,6 +26,7 @@ "projectName": "structures-server", "vmArgs": "-XX:MaxDirectMemorySize=1g -Xmx4096m -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC -cp ${workspaceFolder}/structures-core/src/main/resources", "env": { + "SPRING_PROFILES_ACTIVE": "development,local", "JAVA_TOOL_OPTIONS": "-javaagent:${workspaceFolder}/structures-server/src/main/resources/opentelemetry-javaagent.jar --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4317", "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", @@ -19,8 +34,20 @@ "OTEL_METRICS_EXPORTER": "otlp", "OTEL_TRACES_EXPORTER": "otlp", "OTEL_SERVICE_NAME": "structures-server" - }, - "args": "--spring.profiles.active=development" + } + }, + { + "type": "java", + "name": "Run Java Tests", + "request": "launch", + "mainClass": "", + "projectName": "structures-server", + "args": "", + "vmArgs": "-XX:MaxDirectMemorySize=1g -Xmx4096m -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+ScavengeBeforeFullGC -XX:+DisableExplicitGC", + "env": { + "SPRING_PROFILES_ACTIVE": "test, local", + "JAVA_TOOL_OPTIONS": "--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED" + } } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..d48a3a030 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "npm.packageManager": "pnpm", + "spring-boot.ls.java.home": "${userHome}/.sdkman/candidates/java/21.0.7-ms", + "java.configuration.runtimes": [ + { + "name": "JavaSE-21", + "path": "${userHome}/.sdkman/candidates/java/21.0.7-ms", + "default": true + }, + { + "name": "JavaSE-23", + "path": "${userHome}/.sdkman/candidates/java/23.0.2-amzn" + } + ], + "gradle.debug": true, + "gradle.autoDetect": "on", + "gradle.nestedProjects": false, + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "interactive", + "java.import.gradle.wrapper.enabled": true, + "java.test.config": { + "name": "testConfig", + "testKind": "junit", + "env": { + "JAVA_HOME": "${userHome}/.sdkman/candidates/java/21.0.7-ms", + "JAVA_TOOL_OPTIONS": "-Dspring.profiles.active=development,test --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.lang.in=ALL-UNNAMED" + }, + "vmargs": [ + "-XX:MaxDirectMemorySize=1g", + "-Xmx4096m", + "-XX:+AlwaysPreTouch", + "-XX:+UseG1GC", + "-XX:+ScavengeBeforeFullGC", + "-XX:+DisableExplicitGC" + ], + "workingDirectory": "${workspaceFolder}" + }, + "java.test.defaultConfig": "testConfig", + "java.debug.settings.onBuildFailureProceed": true +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..d083b4dce --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,200 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "gradle", + "id": "${workspaceFolder}/structures-core:build", + "script": "structures-core:build", + "group": "build", + "project": "structures-core", + "buildFile": "${workspaceFolder}/structures-core/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "args": "-x test", + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Build Core", + "description": "Build the core project" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures-sql:build", + "script": "structures-sql:build", + "group": "build", + "project": "structures-sql", + "buildFile": "${workspaceFolder}/structures-sql/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "args": "-x test", + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Build SQL", + "description": "Build the sql project" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures-server:build", + "script": "structures-server:build", + "group": "build", + "project": "structures-server", + "buildFile": "${workspaceFolder}/structures-server/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "args": "-x test", + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Build Server", + "description": "Build the server project" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures:build", + "script": "build", + "group": "build", + "project": "structures", + "buildFile": "${workspaceFolder}/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "args": "-x test", + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Build All", + "description": "Build all projects" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures:clean", + "script": "clean", + "group": "build", + "project": "structures", + "buildFile": "${workspaceFolder}/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "args": "-x test", + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Build All", + "description": "Clean all projects" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures:test", + "script": "test", + "group": "test", + "project": "structures", + "buildFile": "${workspaceFolder}/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Test All", + "description": "Test all projects" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures-server:test", + "script": "structures-server:test", + "group": "test", + "project": "structures-server", + "buildFile": "${workspaceFolder}/structures-server/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Test Server", + "description": "Test the server project" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures-core:test", + "script": "structures-core:test", + "group": "test", + "project": "structures-core", + "buildFile": "${workspaceFolder}/structures-core/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Test Core", + "description": "Test the core project" + }, + { + "type": "gradle", + "id": "${workspaceFolder}/structures-sql:test", + "script": "structures-sql:test", + "group": "test", + "project": "structures-sql", + "buildFile": "${workspaceFolder}/structures-sql/build.gradle", + "projectFolder": "${workspaceFolder}", + "workspaceFolder": "${workspaceFolder}", + "rootProject": "structures", + "javaDebug": true, + "problemMatcher": [ + "$gradle" + ], + "label": "Gradle: Test SQL", + "description": "Test the sql project" + }, + { + "label": "Frontend: Install Dependencies", + "type": "shell", + "command": "pnpm install", + "options": { + "cwd": "${workspaceFolder}/structures-frontend-next" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "Frontend: Dev Server", + "type": "shell", + "command": "pnpm dev", + "options": { + "cwd": "${workspaceFolder}/structures-frontend-next" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "isBackground": true, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index b59d02d99..115f8c0c5 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,22 @@ docker-compose up -d 3. Visit [http://localhost:9090](http://localhost:9090) to access the Structures GUI +For detailed setup instructions, see the [Docker Compose documentation](docker-compose/README.md). + ## Next Steps - [Getting Started Guide](https://kinotic-foundation.github.io/structures/website/guide/getting-started.html) - Complete setup instructions and prerequisites ### Projects -* structures-core +* [structures-core](structures-core/README.md) * Provides the core library for use in all other projects. -* structures-frontend +* [structures-frontend](structures-frontend/README.md) * Provides a GUI for interacting with Structures. -* structures-server - * Provides Access to the core library via a REST API and a GUI. +* [structures-frontend-next](structures-frontend-next/README.md) + * Next-generation Vue 3 frontend application. +* [structures-server](structures-server/README.md) + * Provides access to the core library via a REST API and a GUI. +* [structures-auth](structures-auth/README.md) + * Authentication and authorization library with OIDC support. ### Environment Variables These variables are available for custom configuration, presented are the defaults. @@ -59,3 +65,16 @@ STRUCTURES_ENABLE_STATIC_FILE_SERVER: true STRUCTURES_INITIALIZE_WITH_SAMPLE_DATA: false ``` +### Testing Requirements +When running tests locally or in CI/CD environments, the following environment variable is required: + +```bash +export TESTCONTAINERS_RYUK_DISABLED=true +``` + +**Why this is needed:** TestContainers uses a Ryuk container for resource cleanup, which can cause connectivity issues on certain systems (particularly macOS with Docker Desktop). Disabling Ryuk ensures reliable test execution. + +**CI/CD Consideration:** Add this environment variable to your CI/CD pipeline configuration to ensure tests run successfully. + +**For detailed testing information:** See [TESTING.md](TESTING.md) for comprehensive testing setup, troubleshooting, and CI/CD configuration examples. + diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..356b14174 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,260 @@ +# Testing Guide for Structures + +This document outlines the testing requirements and configuration needed for the Structures project, particularly for CI/CD environments. + +## Overview + +The Structures project uses TestContainers for integration testing with Elasticsearch. This ensures that tests run against real Elasticsearch instances in isolated containers, providing reliable and consistent test results. + +## Prerequisites + +- Docker and Docker Compose running +- Java 17+ for backend tests +- Node.js 22+ for frontend tests + +## Environment Variables + +### Required Environment Variable + +```bash +export TESTCONTAINERS_RYUK_DISABLED=true +``` + +**This environment variable is mandatory for all test execution.** + +### Why This is Required + +TestContainers uses a Ryuk container for resource cleanup and management. However, this container can cause connectivity issues in certain environments: + +- **macOS with Docker Desktop**: Common connectivity issues +- **CI/CD environments**: Network restrictions and container isolation +- **Systems with strict firewalls**: Ryuk container may be blocked +- **Corporate networks**: Proxy and security configurations + +Disabling Ryuk ensures reliable test execution by skipping the cleanup container. While this means containers won't be automatically cleaned up, TestContainers will still manage the lifecycle of test containers. + +## Running Tests Locally + +### Backend Tests (Java) + +```bash +# Set required environment variable +export TESTCONTAINERS_RYUK_DISABLED=true + +# Run all tests +./gradlew test + +# Run specific module tests +./gradlew :structures-core:test +./gradlew :structures-server:test +./gradlew :structures-sql:test + +# Run specific test class +./gradlew :structures-core:test --tests "*StructureCrudTests*" +``` + +### Frontend Tests (TypeScript/JavaScript) + +```bash +# Set required environment variable +export TESTCONTAINERS_RYUK_DISABLED=true + +# Run E2E tests +cd structures-js/structures-e2e +./gradlew pnpmTest + +# Run API tests +cd structures-js/structures-api +./gradlew test +``` + +## CI/CD Configuration + +### GitHub Actions + +Add the environment variable to your workflow: + +```yaml +name: Build and Test +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Build with Gradle + env: + TESTCONTAINERS_RYUK_DISABLED: true + run: ./gradlew build +``` + +### GitLab CI + +```yaml +variables: + TESTCONTAINERS_RYUK_DISABLED: "true" + +test: + script: + - ./gradlew build +``` + +### Jenkins + +```groovy +pipeline { + agent any + environment { + TESTCONTAINERS_RYUK_DISABLED = 'true' + } + stages { + stage('Test') { + steps { + sh './gradlew build' + } + } + } +} +``` + +### Azure DevOps + +```yaml +variables: + TESTCONTAINERS_RYUK_DISABLED: 'true' + +steps: +- script: ./gradlew build +``` + +### CircleCI + +```yaml +jobs: + test: + docker: + - image: openjdk:21 + environment: + TESTCONTAINERS_RYUK_DISABLED: true + steps: + - run: ./gradlew build +``` + +## Test Architecture + +### TestContainers Usage + +The project uses TestContainers in several modules: + +1. **structures-core**: `ElasticsearchTestBase` for core functionality tests +2. **structures-server**: `ElasticsearchTestContainer` for server integration tests +3. **structures-sql**: `ElasticsearchSqlTestBase` for SQL parsing and execution tests + +### Elasticsearch Configuration + +All test containers use Elasticsearch version `8.18.1` with the following configuration: + +```java +.withEnv("discovery.type", "single-node") +.withEnv("xpack.security.enabled", "false") +.withEnv("xpack.ml.enabled", "false") +.withEnv("xpack.watcher.enabled", "false") +.withEnv("xpack.monitoring.enabled", "false") +.withEnv("xpack.security.enrollment.enabled", "false") +.withEnv("xpack.security.http.ssl.enabled", "false") +.withEnv("xpack.security.transport.ssl.enabled", "false") +``` + +## Troubleshooting + +### Common Issues + +1. **Ryuk Connection Failed** + ``` + Could not connect to Ryuk at localhost:XXXXX + ``` + **Solution**: Set `TESTCONTAINERS_RYUK_DISABLED=true` + +2. **Elasticsearch Container Won't Start** + ``` + Elasticsearch container failed to start + ``` + **Solution**: Ensure Docker has sufficient resources (at least 4GB RAM) + +3. **Port Conflicts** + ``` + Port XXXX is already in use + ``` + **Solution**: Stop other services using the same ports or use different ports + +4. **Permission Denied** + ``` + Permission denied when starting containers + ``` + **Solution**: Ensure Docker daemon is running and user has proper permissions + +### Debug Mode + +To enable TestContainers debug logging, add this environment variable: + +```bash +export TESTCONTAINERS_DEBUG=true +``` + +This will provide detailed information about container startup, networking, and cleanup processes. + +## Performance Considerations + +### Resource Requirements + +- **Minimum RAM**: 4GB for running tests with Elasticsearch +- **Recommended RAM**: 8GB+ for optimal performance +- **Disk Space**: At least 2GB free space for container images + +### Test Execution Time + +- **Unit Tests**: 1-2 minutes +- **Integration Tests**: 5-10 minutes (includes container startup) +- **Full Test Suite**: 15-30 minutes depending on system resources + +### Optimization Tips + +1. **Parallel Execution**: Tests run in parallel by default +2. **Container Reuse**: TestContainers reuses containers when possible +3. **Resource Limits**: Consider setting Docker resource limits for CI/CD environments + +## Security Considerations + +### Container Isolation + +- Test containers run in isolated networks +- No persistent data between test runs +- Containers are automatically removed after tests complete + +### Network Access + +- Test containers only have access to the test network +- No external network access required +- All communication is internal to the test environment + +## Support + +If you encounter issues with testing: + +1. Check that `TESTCONTAINERS_RYUK_DISABLED=true` is set +2. Ensure Docker is running and accessible +3. Verify sufficient system resources +4. Check the troubleshooting section above +5. Open an issue in the project repository with: + - Error messages + - Environment details (OS, Docker version, Java version) + - Test command that failed diff --git a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle index 37bb6a95d..d5b879a47 100644 --- a/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle +++ b/buildSrc/src/main/groovy/org.kinotic.java-common-conventions.gradle @@ -76,9 +76,15 @@ dependencyManagement { dependency "org.kinotic:continuum-core-vertx:${continuumVersion}" dependency "org.kinotic:continuum-gateway:${continuumVersion}" dependency "org.kinotic:continuum-idl:${continuumVersion}" + dependency "org.kinotic:continuum-security:${continuumVersion}" // must be overridden for the elastic client dependency 'jakarta.json:jakarta.json-api:2.0.1' + + // JWT and OIDC dependencies + dependency "io.jsonwebtoken:jjwt-api:${jsonwebtokenVersion}" + dependency "io.jsonwebtoken:jjwt-impl:${jsonwebtokenVersion}" + dependency "io.jsonwebtoken:jjwt-jackson:${jsonwebtokenVersion}" } } @@ -105,3 +111,11 @@ test { useJUnitPlatform() environment("JAVA_TOOL_OPTIONS", "-javaagent:${configurations.agent.singleFile} --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED") } + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + withJavadocJar() + withSourcesJar() +} diff --git a/cursorrules b/cursorrules index 1a7eb8029..87bee07cb 100644 --- a/cursorrules +++ b/cursorrules @@ -1,4 +1,18 @@ -# Structures Project - Global Development Guidelines +# Cursor Rules for Structures Project + +## Priority Constraints +1. **Stop and Ask When Uncertain**: If we don't know exactly how to solve something, we should stop and ask the user for guidance rather than blindly trying different approaches. This prevents wasted time and potential regressions. +2. **Follow Repository-Specific Guidelines**: Always adhere to the structures project architecture and patterns. +3. **Test-Driven Development**: Write tests first, then implement functionality. +4. **Documentation First**: Update documentation before making changes. +5. **Security-First Approach**: When dealing with authentication, authorization, or access controls, always ask for guidance on the correct approach to avoid creating security vulnerabilities. Never assume cross-provider configurations are safe without explicit confirmation. + +## Gradle Build Commands +**IMPORTANT**: This is a multi-module Gradle project. Always run Gradle commands from the project root directory using the module syntax: +- ✅ Correct: `./gradlew :structures-server:build` (from project root) +- ❌ Incorrect: `cd structures-server && ./gradlew build` +- ✅ Correct: `./gradlew :structures-server:test` +- ❌ Incorrect: `cd structures-server && ../gradlew test` ## Project Overview This is a multi-module Java/TypeScript/JavaScript project implementing a comprehensive data platform with GraphQL federation, OIDC authentication, and Elasticsearch integration. diff --git a/docker-compose/KEYCLOAK_README.md b/docker-compose/KEYCLOAK_README.md new file mode 100644 index 000000000..d6100549f --- /dev/null +++ b/docker-compose/KEYCLOAK_README.md @@ -0,0 +1,77 @@ +# Keycloak Test Realm Configuration + +This directory contains the configuration files for setting up a test Keycloak realm for the Structures project. + +## Files + +### `keycloak-test-realm.json` +Complete realm configuration file that can be imported into Keycloak. This file contains: +- Realm settings (test realm) +- Client configuration (structures-client as a public client) +- Protocol mappers for OIDC claims +- User roles (user, admin) +- Test user (testuser@example.com / password123) + +### `keycloak-import-test-realm.sh` +Script to import the realm configuration into Keycloak. This script: +- Waits for Keycloak to be ready +- Gets an admin token +- Imports the realm configuration from the JSON file + +### `compose.keycloak.yml` +Docker Compose file that sets up: +- PostgreSQL database for Keycloak +- Keycloak server +- Initialization container that imports the realm + +## Usage + +### Starting the services +```bash +docker-compose -f docker-compose/compose.keycloak.yml up -d +``` + +### Viewing logs +```bash +docker logs keycloak-init +``` + +### Accessing Keycloak +- **URL**: http://localhost:8888/auth +- **Admin Console**: http://localhost:8888/auth/admin +- **Admin Credentials**: admin / admin + +### Test Realm Details +- **Realm**: test +- **Client ID**: structures-client (Public Client) +- **Test User**: testuser@example.com / password123 +- **Redirect URIs**: + - http://localhost:5173/* + - http://localhost:3000/* +- **Protocol Mappers**: email, name, family name, preferred_username, audience, tenantId + +## Modifying the Configuration + +To modify the realm configuration: + +1. Edit `keycloak-test-realm.json` +2. Restart the services: + ```bash + docker-compose -f docker-compose/compose.keycloak.yml down + docker-compose -f docker-compose/compose.keycloak.yml up -d + ``` + +## OIDC Configuration + +The structures-client is configured as a public client suitable for web applications: +- `publicClient: true` +- `serviceAccountsEnabled: false` +- Standard OIDC authorization code flow enabled +- Direct access grants enabled for testing + +## Frontend Integration + +The frontend should be configured to use: +- **Authority**: http://localhost:8888/auth/realms/test +- **Client ID**: structures-client +- **Redirect URI**: http://localhost:5173/auth/callback (or your frontend port) diff --git a/docker-compose/README.md b/docker-compose/README.md new file mode 100644 index 000000000..4440e54aa --- /dev/null +++ b/docker-compose/README.md @@ -0,0 +1,307 @@ +# Docker Compose Services + +This directory contains Docker Compose configurations for running the Structures framework and its dependencies in containerized environments. + +## Overview + +The Docker Compose files provide different service configurations for: +- **Development**: Local development environment with all services +- **Testing**: Isolated testing environment +- **Monitoring**: Observability stack with metrics and logging +- **Authentication**: Identity providers for testing and development + +## Available Compose Files + +### Core Services + +#### `compose.yml` - Main Development Stack +- **Purpose**: Complete development environment +- **Services**: Elasticsearch, Kibana, Structures Server +- **Ports**: + - Elasticsearch: 9200 + - Kibana: 5601 + - Structures Server: 9090 +- **Usage**: `docker-compose up -d` + +#### `compose.test.yml` - Testing Environment +- **Purpose**: Isolated testing environment +- **Services**: Elasticsearch, Structures Server (test profile) +- **Ports**: + - Elasticsearch: 9201 + - Structures Server: 9091 +- **Usage**: `docker-compose -f compose.test.yml up -d` + +### Authentication Services + +#### `compose.keycloak.yml` - Keycloak Identity Provider +- **Purpose**: OIDC authentication provider for development +- **Services**: PostgreSQL, Keycloak, Initialization container +- **Ports**: + - Keycloak: 8888 + - PostgreSQL: 5432 +- **Usage**: `docker-compose -f compose.keycloak.yml up -d` +- **Documentation**: [Keycloak Setup Guide](KEYCLOAK_README.md) + +### Monitoring & Observability + +#### `compose-otel.yml` - OpenTelemetry Stack +- **Purpose**: Distributed tracing and metrics collection +- **Services**: OpenTelemetry Collector, Jaeger +- **Ports**: + - Collector: 4317, 4318 + - Jaeger: 16686 +- **Usage**: `docker-compose -f compose-otel.yml up -d` + +#### `compose.ek-stack.yml` - Elastic Stack +- **Purpose**: Log aggregation and analysis +- **Services**: Elasticsearch, Logstash, Kibana +- **Ports**: + - Elasticsearch: 9200 + - Logstash: 5044 + - Kibana: 5601 +- **Usage**: `docker-compose -f compose.ek-stack.yml up -d` + +### Utility Services + +#### `compose.gen-schemas.yml` - Schema Generation +- **Purpose**: Generate GraphQL schemas from Elasticsearch mappings +- **Services**: Schema generator utility +- **Usage**: `docker-compose -f compose.gen-schemas.yml up` + +### Override Files + +#### `compose.test.override.yml` - Test Overrides +- **Purpose**: Override default settings for testing +- **Usage**: `docker-compose -f compose.yml -f compose.test.override.yml up -d` + +#### `compose.ek-m4.override.yml` - M4 Mac Overrides +- **Purpose**: Optimize for Apple Silicon Macs +- **Usage**: `docker-compose -f compose.ek-stack.yml -f compose.ek-m4.override.yml up -d` + +#### `compose.ek-transient.override.yml` - Transient Storage +- **Purpose**: Use temporary storage for development +- **Usage**: `docker-compose -f compose.ek-stack.yml -f compose.ek-transient.override.yml up -d` + +## Quick Start + +### 1. Start Basic Development Environment + +```bash +# Start core services +docker-compose up -d + +# Check service status +docker-compose ps + +# View logs +docker-compose logs -f +``` + +### 2. **Recommended: Full Development Environment (M4 Mac)** + +For developers on Apple Silicon Macs who want the complete setup with authentication and data seeding: + +```bash +# Start all services optimized for M4 Mac with Keycloak and schema generation +docker-compose -f compose.yml -f compose.ek-m4.override.yml -f compose.gen-schemas.yml -f compose.keycloak.yml up -d +``` + +This command: +- Uses M4-optimized settings for better performance +- Includes Keycloak for OIDC authentication testing +- Generates test structures and data for them +- Starts all core services (Elasticsearch, Kibana, Structures Server) + +### 3. Start with Authentication Only + +```bash +# Start Keycloak for OIDC testing +docker-compose -f compose.keycloak.yml up -d + +# Start main services with auth +docker-compose up -d +``` + +### 4. Start with Monitoring + +```bash +# Start observability stack +docker-compose -f compose-otel.yml up -d + +# Start main services +docker-compose up -d +``` + +## Service Configuration + +### Elasticsearch +- **Version**: 8.x +- **Memory**: 2GB minimum +- **Storage**: Persistent volumes for data +- **Security**: Basic authentication enabled +- **Default Credentials**: elastic/changeme + +### Keycloak +- **Version**: Latest +- **Database**: PostgreSQL 13 +- **Realm**: Pre-configured test realm +- **Default Admin**: admin/admin +- **Test User**: testuser@example.com/password123 + +### Structures Server +- **Profile**: Development by default +- **Port**: 9090 (configurable) +- **Health Check**: /health endpoint +- **Logging**: Structured JSON logging + +## Environment Variables + +### Common Variables +```bash +# Elasticsearch +ELASTIC_PASSWORD=changeme +ELASTIC_USERNAME=elastic + +# Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +POSTGRES_PASSWORD=password + +# Structures +STRUCTURES_ELASTIC_HOST=elasticsearch +STRUCTURES_ELASTIC_PORT=9200 +``` + +### Customization +Create `.env` files for environment-specific configuration: + +```bash +# .env.local +ELASTIC_PASSWORD=mysecurepassword +STRUCTURES_SERVER_PORT=9091 +``` + +## Development Workflow + +### 1. Local Development +```bash +# Start all services +docker-compose up -d + +# Access services +# - Structures: http://localhost:9090 +# - Elasticsearch: http://localhost:9200 +# - Kibana: http://localhost:5601 +``` + +**Pro Tip for M4 Mac Users**: Use the full development command for the best experience: +```bash +docker-compose -f compose.yml -f compose.ek-m4.override.yml -f compose.gen-schemas.yml -f compose.keycloak.yml up -d +``` + +### 2. Testing +```bash +# Start test environment +docker-compose -f compose.test.yml up -d + +# Run tests +./gradlew test + +# Clean up +docker-compose -f compose.test.yml down -v +``` + +### 3. Debugging +```bash +# View service logs +docker-compose logs -f structures-server + +# Access service shell +docker-compose exec elasticsearch bash + +# Check service health +curl http://localhost:9090/health +``` + +## Troubleshooting + +### Common Issues + +1. **Port Conflicts** + ```bash + # Check what's using a port + lsof -i :9200 + + # Use different ports + docker-compose -f compose.yml -f compose.test.override.yml up -d + ``` + +2. **Service Startup Order** + ```bash + # Wait for dependencies + docker-compose up -d elasticsearch + sleep 30 + docker-compose up -d structures-server + ``` + +3. **Memory Issues** + ```bash + # Increase Docker memory limit + # Docker Desktop: Settings > Resources > Memory + ``` + +4. **Storage Issues** + ```bash + # Clean up volumes + docker-compose down -v + docker volume prune + ``` + +### Debug Commands + +```bash +# Check service status +docker-compose ps + +# View service logs +docker-compose logs -f [service-name] + +# Access service container +docker-compose exec [service-name] bash + +# Check service health +curl http://localhost:9090/health +curl http://localhost:9200/_cluster/health +``` + +## Production Considerations + +### Security +- Change default passwords +- Use secrets management +- Enable TLS/HTTPS +- Restrict network access + +### Performance +- Adjust memory limits +- Use SSD storage +- Configure resource limits +- Monitor resource usage + +### Monitoring +- Enable health checks +- Set up log aggregation +- Configure metrics collection +- Implement alerting + +## Related Documentation + +- [Keycloak Setup](KEYCLOAK_README.md) - Detailed Keycloak configuration +- [Structures Server](../structures-server/README.md) - Backend server documentation +- [Getting Started](../../webdocs/guide/getting-started.md) - Complete setup guide + +## Contributing + +1. Test compose files with different environments +2. Update documentation for new services +3. Ensure backward compatibility +4. Add health checks for new services diff --git a/docker-compose/compose.keycloak.yml b/docker-compose/compose.keycloak.yml new file mode 100644 index 000000000..f7657bfa4 --- /dev/null +++ b/docker-compose/compose.keycloak.yml @@ -0,0 +1,91 @@ +services: + structures-server: + environment: + - OIDC_SECURITY_SERVICE_ENABLED=true + - OIDC_SECURITY_SERVICE_DEBUG=true + - OIDC_SECURITY_SERVICE_FRONTEND_CONFIGURATION_PATH=/app-config.override.json + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_PROVIDER=keycloak + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_DISPLAY_NAME=Keycloak + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_ENABLED=true + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_ROLES_CLAIM_PATH=realm_access.roles + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_DOMAINS_0=example.com + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_ROLES_0=admin + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_AUDIENCE=structures-client + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_CLIENT_ID=structures-client + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_AUTHORITY=http://localhost:8888/auth/realms/test + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_REDIRECT_URI=http://localhost:9090/login + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_POST_LOGOUT_REDIRECT_URI=http://localhost:9090 + - OIDC_SECURITY_SERVICE_OIDC_PROVIDERS_0_SILENT_REDIRECT_URI=http://localhost:9090/login/silent-renew + + keycloak-db: + container_name: keycloak-db + image: postgres:15-alpine + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + volumes: + - keycloak-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - structures-network + + keycloak: + container_name: keycloak + image: quay.io/keycloak/keycloak:24.0.2 + command: start-dev + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 8888 + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_HTTP_ENABLED: "true" + KC_HTTP_RELATIVE_PATH: /auth + KC_METRICS_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + KC_HTTP_PORT: "8888" + ports: + - "127.0.0.1:8888:8888" + depends_on: + keycloak-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "timeout 10 bash -c '&1 | head -10 + +# Check if the health endpoint exists +echo "" +echo "4. Checking if health endpoint exists..." +curl -v http://localhost:8888/auth/health/ 2>&1 | head -10 + +# Check if Keycloak is responding at all +echo "" +echo "5. Checking if Keycloak responds..." +curl -v http://localhost:8888/ 2>&1 | head -10 + +# Check container network +echo "" +echo "6. Checking container network..." +docker exec keycloak netstat -tlnp 2>/dev/null || echo "netstat not available in container" + +# Check if curl is available in container +echo "" +echo "7. Checking if curl is available in container..." +docker exec keycloak which curl 2>/dev/null || echo "curl not found in container" + +# Check if wget is available in container +echo "" +echo "8. Checking if wget is available in container..." +docker exec keycloak which wget 2>/dev/null || echo "wget not found in container" + +echo "" +echo "=== Debug Complete ===" \ No newline at end of file diff --git a/docker-compose/keycloak-import-test-realm.sh b/docker-compose/keycloak-import-test-realm.sh new file mode 100755 index 000000000..a3a20d908 --- /dev/null +++ b/docker-compose/keycloak-import-test-realm.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +echo "Waiting for Keycloak to be fully ready..." +sleep 15 + +echo "Getting admin token..." +ADMIN_TOKEN=$(curl -s -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=admin&grant_type=password&client_id=admin-cli" \ + http://keycloak:8888/auth/realms/master/protocol/openid-connect/token | \ + grep -o '"access_token":"[^"]*"' | cut -d'"' -f4) + +if [ -z "$ADMIN_TOKEN" ]; then + echo "Failed to get admin token" + exit 1 +fi + +echo "Admin token obtained" + +echo "Importing test realm from configuration file..." +curl -s -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d @/keycloak-test-realm.json \ + http://keycloak:8888/auth/admin/realms + +if [ $? -eq 0 ]; then + echo "Test realm imported successfully!" + echo "Realm: test" + echo "Client ID: structures-client (Public Client)" + echo "Test user: testuser@example.com / password123" + echo "Protocol mappers: email, name, family name, preferred_username, audience, tenantId" +else + echo "Failed to import test realm" + exit 1 +fi diff --git a/docker-compose/keycloak-test-realm.json b/docker-compose/keycloak-test-realm.json new file mode 100644 index 000000000..076240f52 --- /dev/null +++ b/docker-compose/keycloak-test-realm.json @@ -0,0 +1,254 @@ +{ + "realm": "test", + "enabled": true, + "displayName": "Test Realm", + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "accessTokenLifespan": 300, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespanForImplicitFlow": 900, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "internationalizationEnabled": false, + "supportedLocales": [], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + }, + "clients": [ + { + "clientId": "structures-client", + "name": "Structures Application", + "description": "Structures frontend application", + "enabled": true, + "publicClient": true, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "redirectUris": [ + "http://localhost:5173/*", + "http://localhost:5173/auth/callback", + "http://localhost:3000/*", + "http://localhost:3000/auth/callback" + ], + "webOrigins": [ + "http://localhost:5173", + "http://localhost:3000", + "+" + ], + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "name": "client-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "claim.name": "aud", + "claim.value": "structures-client", + "jsonType.label": "String" + } + }, + { + "name": "tenantId", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "claim.name": "tenantId", + "claim.value": "kinotic", + "jsonType.label": "String" + } + } + ] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Default user role" + }, + { + "name": "admin", + "description": "Administrator role" + } + ] + }, + "users": [ + { + "username": "testuser@example.com", + "email": "testuser@example.com", + "firstName": "Test", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "realm_access": { + "roles": ["admin", "user"] + } + } + ] +} diff --git a/gradle.properties b/gradle.properties index a83b042e4..6ef81e15f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,6 +14,7 @@ graphQlJavaVersion=22.3 groovyVersion=4.0.24 igniteVersion=2.16.0 jreleaserVersion=1.19.0 +jsonwebtokenVersion=0.12.6 otelBomVersion=1.48.0 otelInstrumentationBomVersion=2.13.3 lombokPluginVersion=8.13.1 @@ -26,4 +27,4 @@ springDependencyManagementVersion=1.1.7 swaggerCoreVersion=2.2.27 testContainersVersion=1.20.4 vertxVersion=4.5.13 -vertxCompletableFutureVersion=0.1.2 +vertxCompletableFutureVersion=0.1.2 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f..1b33c55ba 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138c..ff23a68d7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf133..23d15a936 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -203,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -211,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 7101f8e46..5eed7ee84 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -68,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..89313554f --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +java = "21" diff --git a/settings.gradle b/settings.gradle index ac453e119..881692cb8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,22 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + maven { + url = uri('https://s01.oss.sonatype.org/content/repositories/snapshots/') + } + + } +} + rootProject.name = 'structures' -//includeBuild('../continuum-framework') rootDir.listFiles().each { if (it.directory && new File(it, 'settings.gradle').exists()) { diff --git a/structures-auth/.gitignore b/structures-auth/.gitignore new file mode 100644 index 000000000..d320a2670 --- /dev/null +++ b/structures-auth/.gitignore @@ -0,0 +1,43 @@ +# Gradle +.gradle/ +build/ +bin/ + +# IDE +.idea/ +*.iml +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env \ No newline at end of file diff --git a/structures-auth/README.md b/structures-auth/README.md new file mode 100644 index 000000000..d23746803 --- /dev/null +++ b/structures-auth/README.md @@ -0,0 +1,276 @@ +# Structures Auth Library + +A comprehensive JWT authentication and authorization library for the Structures framework. + +## Overview + +The Structures Auth library provides JWT token management, OIDC integration, and authorization services for Spring Boot applications. It follows the standard Structures library conventions and integrates seamlessly with the broader Structures ecosystem. + +## Features + +- **JWT Token Management**: Create, validate, and parse JWT tokens +- **OIDC Integration**: OpenID Connect authentication support +- **Authorization Services**: Role-based access control and permission management +- **Spring Boot Auto-Configuration**: Automatic configuration for Spring Boot applications +- **Security Best Practices**: Secure token handling and validation + +## Dependencies + +- **JJWT**: JWT token creation and validation +- **Jackson**: JSON processing for token claims +- **Continuum Core**: Framework integration +- **Spring Boot**: Auto-configuration and dependency injection + +## Quick Start + +### 1. Add the dependency + +```gradle +implementation project(':structures-auth') +``` + +### 2. Enable auto-configuration + +The library automatically configures OIDC security when the `oidc-security-service.enabled` property is set to `true`. No additional annotations are required. + +```java +@SpringBootApplication +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} +``` + +### 3. Configure OIDC providers + +```yaml +oidc-security-service: + enabled: true + debug: false + tenant-id-field-name: "tenantId" + frontend-configuration-path: "/app-config.override.json" + oidc-providers: + - provider: "keycloak" + display-name: "Keycloak" + enabled: true + client-id: "structures-client" + authority: "http://localhost:8888/auth/realms/test" + redirect-uri: "http://localhost:5173/login" + post-logout-redirect-uri: "http://localhost:5173" + silent-redirect-uri: "http://localhost:5173/login/silent-renew" + domains: + - "example.com" + audience: "structures-client" + roles-claim-path: "realm_access.roles" + additional-scopes: "groups" +``` + +### 4. Use the services + +```java +@Service +public class MyService { + + @Autowired + private SecurityService securityService; + + public void processRequest(HttpServletRequest request) { + // OIDC token validation is automatic + String userId = securityService.getCurrentUserId(); + Set roles = securityService.getCurrentUserRoles(); + + if (roles.contains("admin")) { + // Perform admin operations + } + } +} +``` + +## Configuration + +The library provides sensible defaults but can be customized through Spring Boot configuration properties: + +```yaml +oidc-security-service: + enabled: true + debug: false + tenant-id-field-name: "tenantId" + frontend-configuration-path: "/app-config.override.json" + oidc-providers: + - provider: "keycloak" + display-name: "Keycloak" + enabled: true + client-id: "structures-client" + authority: "http://localhost:8888/auth/realms/test" + redirect-uri: "http://localhost:5173/login" + post-logout-redirect-uri: "http://localhost:5173" + silent-redirect-uri: "http://localhost:5173/login/silent-renew" + domains: + - "example.com" + audience: "structures-client" + roles-claim-path: "realm_access.roles" + additional-scopes: "groups" + + - provider: "okta" + display-name: "Okta" + enabled: true + client-id: "your-okta-client-id" + authority: "https://your-okta-domain.okta.com/oauth2/default" + redirect-uri: "http://localhost:5173/login" + post-logout-redirect-uri: "http://localhost:5173" + silent-redirect-uri: "http://localhost:5173/login/silent-renew" + domains: + - "yourcompany.com" + audience: "your-okta-client-id" + roles-claim-path: "roles" + additional-scopes: "groups" +``` + +## API Reference + +### SecurityService + +- `getCurrentUserId()`: Gets the current authenticated user ID +- `getCurrentUserRoles()`: Gets all roles for the current user +- `hasRole(String role)`: Checks if the current user has a specific role +- `hasAnyRole(Set roles)`: Checks if the current user has any of the specified roles +- `isAuthenticated()`: Checks if the current user is authenticated +- `getCurrentUserClaims()`: Gets all claims for the current user + +### OIDC Configuration + +- **Automatic JWT Validation**: JWT tokens are automatically validated on each request +- **Multi-Provider Support**: Support for multiple OIDC providers simultaneously +- **JWKS Caching**: Efficient JSON Web Key Set caching for performance +- **Role Extraction**: Automatic role extraction from JWT claims + +### Configuration Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `enabled` | boolean | `false` | Master switch for OIDC security service | +| `debug` | boolean | `false` | Enable debug logging and UI debugging | +| `tenant-id-field-name` | string | `"tenantId"` | JWT claim field name for tenant ID | +| `frontend-configuration-path` | string | `"/app-config.override.json"` | Path for frontend configuration overrides | +| `oidc-providers` | array | `[]` | List of OIDC provider configurations | + +#### OIDC Provider Properties + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `provider` | string | Yes | Unique identifier for the provider | +| `display-name` | string | Yes | Human-readable provider name | +| `enabled` | boolean | Yes | Enable/disable this specific provider | +| `client-id` | string | Yes | OAuth client ID from the provider | +| `authority` | string | Yes | OIDC issuer authority URL | +| `redirect-uri` | string | Yes | OAuth redirect URI after authentication | +| `post-logout-redirect-uri` | string | Yes | Redirect URI after logout | +| `silent-redirect-uri` | string | Yes | URI for silent token renewal | +| `domains` | array | Yes | Email domains this provider handles | +| `audience` | string | Yes | Expected audience claim in JWT tokens | +| `roles-claim-path` | string | No | JSON path to roles claim in JWT | +| `additional-scopes` | string | No | Additional OAuth scopes to request | +| `roles` | array | No | Default roles for this provider | +| `metadata` | object | No | Additional provider metadata | + +## Security Considerations + +- **JWT Validation**: All JWT tokens are automatically validated for signature, expiration, and claims +- **Issuer Verification**: Only tokens from configured, trusted issuers are accepted +- **Audience Validation**: Tokens must have valid audience claims for your application +- **Role-Based Access**: Implement role-based access control using extracted JWT claims +- **HTTPS Required**: Always use HTTPS in production for secure token transmission +- **Token Storage**: Store tokens securely in memory, never in localStorage or cookies +- **Regular Updates**: Keep OIDC provider configurations and JWKS endpoints up to date + +## Testing + +The library includes comprehensive tests: + +```bash +./gradlew :structures-auth:test +``` + +## Contributing + +1. Follow the existing code conventions +2. Add tests for new functionality +3. Update documentation as needed +4. Ensure security best practices are followed +5. Test with multiple OIDC providers +6. Validate JWT token handling thoroughly + +## How It Works + +### 1. **JWT Token Extraction** +- Automatically extracts JWT tokens from `Authorization: Bearer ` headers +- Supports both access tokens and ID tokens + +### 2. **Token Validation** +- Validates JWT signature using JWKS from the issuer +- Verifies issuer against configured OIDC providers +- Checks audience claims against provider configuration +- Validates token expiration + +### 3. **User Creation** +- Creates `Participant` objects from JWT claims +- Extracts user information (email, name, roles) +- Maps email domains to appropriate OIDC providers +- Applies role-based access control + +### 4. **Frontend Integration** +- Serves configuration overrides at `/app-config.override.json` +- Enables dynamic frontend configuration without rebuilds +- Supports runtime provider enable/disable + +## Examples + +### Keycloak Configuration +```yaml +oidc-security-service: + enabled: true + oidc-providers: + - provider: "keycloak" + display-name: "Keycloak" + enabled: true + client-id: "structures-client" + authority: "http://localhost:8888/auth/realms/test" + redirect-uri: "http://localhost:5173/login" + post-logout-redirect-uri: "http://localhost:5173" + silent-redirect-uri: "http://localhost:5173/login/silent-renew" + domains: + - "example.com" + audience: "structures-client" + roles-claim-path: "realm_access.roles" +``` + +### Okta Configuration +```yaml +oidc-security-service: + enabled: true + oidc-providers: + - provider: "okta" + display-name: "Okta" + enabled: true + client-id: "0oaowrlsm5Ua1vWD85d7" + authority: "https://dev-39125344.okta.com/oauth2/default" + redirect-uri: "http://localhost:5173/login" + post-logout-redirect-uri: "http://localhost:5173" + silent-redirect-uri: "http://localhost:5173/login/silent-renew" + domains: + - "yourcompany.com" + audience: "0oaowrlsm5Ua1vWD85d7" + roles-claim-path: "roles" +``` + +## Related Documentation + +For detailed OIDC configuration and troubleshooting, see: +- [OIDC Implementation Guide](oidc-docs/OIDC_IMPLEMENTATION.md) +- [Provider-Specific Guides](oidc-docs/) - Okta, Microsoft, Keycloak, and more +- [Frontend Configuration](../structures-frontend-next/CONFIGURATION.md) + +## License + +This library is part of the Structures framework and follows the same licensing terms. \ No newline at end of file diff --git a/structures-auth/build.gradle b/structures-auth/build.gradle new file mode 100644 index 000000000..dfd64bbe9 --- /dev/null +++ b/structures-auth/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'org.kinotic.java-library-conventions' + id 'io.freefair.lombok' +} + +dependencies { + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation "org.kinotic:continuum-core" + + implementation 'io.jsonwebtoken:jjwt-api' + implementation 'io.jsonwebtoken:jjwt-impl' + implementation 'io.jsonwebtoken:jjwt-jackson' + + implementation 'com.fasterxml.jackson.core:jackson-annotations' + implementation 'com.fasterxml.jackson.core:jackson-core' + implementation 'com.fasterxml.jackson.core:jackson-databind' + + implementation 'com.github.ben-manes.caffeine:caffeine' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'com.fasterxml.jackson.core:jackson-databind' + testImplementation "org.testcontainers:testcontainers:${testContainersVersion}" + testImplementation "org.testcontainers:junit-jupiter:${testContainersVersion}" + testImplementation "org.testcontainers:elasticsearch:${testContainersVersion}" + testImplementation 'com.github.dasniko:testcontainers-keycloak:3.8.0' + testImplementation 'junit:junit' +} diff --git a/structures-auth/cursorrules b/structures-auth/cursorrules new file mode 100644 index 000000000..afcbf5f25 --- /dev/null +++ b/structures-auth/cursorrules @@ -0,0 +1,113 @@ +# Structures Auth Library - Development Guidelines + +## Overview +This library provides JWT authentication and authorization functionality for the Structures framework. + +## Architecture +- **JWT Token Management**: Token creation, validation, and parsing +- **OIDC Integration**: OpenID Connect authentication support +- **Authorization**: Role-based access control and permission management +- **Spring Boot Auto-Configuration**: Automatic configuration for Spring Boot applications + +## Key Components + +### JWT Token Services +- Token creation and signing +- Token validation and parsing +- Claims extraction and validation +- Signature verification + +### OIDC Authentication +- OIDC provider integration +- Token exchange and validation +- User information retrieval +- Multi-provider support + +### Authorization Services +- Role-based access control +- Permission checking +- Tenant-aware authorization +- Policy enforcement + +### Spring Boot Integration +- Auto-configuration classes +- Configuration properties +- Starter dependencies +- Health indicators + +## Development Guidelines + +### Code Organization +- Follow the `org.kinotic.structures.auth` package structure +- Use Spring Boot conventions for configuration +- Implement proper error handling and logging +- Follow security best practices + +### Testing Strategy +- Unit tests for all services +- Integration tests for OIDC flows +- Security testing for token validation +- Performance testing for token operations + +### Security Considerations +- Never log sensitive information +- Validate all inputs +- Use secure random for token generation +- Implement proper key management +- Follow OWASP security guidelines + +### Configuration +- Use `@ConfigurationProperties` for externalized config +- Support environment-specific configuration +- Provide sensible defaults +- Document all configuration options + +### Dependencies +- JWT libraries: jjwt-api, jjwt-impl, jjwt-jackson +- Jackson for JSON processing +- Continuum Core for framework integration +- Spring Boot for auto-configuration + +## Integration Patterns + +### Library Usage +```java +@SpringBootApplication +@EnableStructuresAuth +public class MyApplication { + // Auto-configured JWT services +} +``` + +### Configuration Example +```yaml +structures: + auth: + jwt: + issuer: "https://your-issuer.com" + audience: "your-audience" + signing-key: "your-signing-key" + oidc: + providers: + - name: "okta" + issuer: "https://your-okta-domain.okta.com" + client-id: "your-client-id" +``` + +## API Design +- Provide fluent APIs for token operations +- Use builder patterns for complex configurations +- Implement proper exception handling +- Support async operations where appropriate + +## Performance Considerations +- Cache JWKS (JSON Web Key Sets) +- Optimize token validation +- Use connection pooling for OIDC providers +- Implement proper resource cleanup + +## Documentation +- Comprehensive Javadoc for all public APIs +- Usage examples and best practices +- Security guidelines and recommendations +- Integration guides for different scenarios \ No newline at end of file diff --git a/structures-auth/oidc-docs/OIDC_IMPLEMENTATION.md b/structures-auth/oidc-docs/OIDC_IMPLEMENTATION.md new file mode 100644 index 000000000..a22fb1cb8 --- /dev/null +++ b/structures-auth/oidc-docs/OIDC_IMPLEMENTATION.md @@ -0,0 +1,776 @@ +# OIDC Authentication Implementation + +This document describes the OIDC (OpenID Connect) authentication implementation for the Structures project. + +## Overview + +The OIDC implementation provides JWT token-based authentication with the following features: + +- **JWKS Caching**: Efficient caching of JSON Web Key Sets using Caffeine +- **Issuer Validation**: Validates JWT tokens against configured allowed issuers +- **Audience Validation**: Validates JWT tokens against configured allowed audiences +- **Token Expiration**: Automatically checks token expiration +- **Well-known Configuration**: Fetches and caches OIDC provider configuration +- **Role Extraction**: Extracts user roles from JWT claims +- **Tenant Support**: Supports multi-tenant applications with tenant ID extraction +- **Frontend Integration**: Complete Vue.js frontend integration with navigation support +- **Multi-Provider Support**: Supports Okta, Keycloak, Google, Microsoft, and custom providers +- **State Management**: Robust state handling with base64 encoding and localStorage persistence + +## Components + +### 1. JwksService +Handles JWKS (JSON Web Key Set) operations: +- Fetches well-known OIDC configuration +- Caches JWKS keys and configurations +- Extracts key information from JWT tokens +- Provides efficient key lookup by issuer and key ID + +### 2. OidcAuthVerifier +Main authentication component: +- Implements SecurityService interface +- Validates JWT tokens using JJWT 0.12.x +- Creates Participant objects from JWT claims +- Handles issuer and audience validation +- Supports multiple audience validation + +### 3. OidcAuthVerifierProperties +Configuration properties for OIDC: +- `enabled`: Enable/disable OIDC authentication +- `allowedIssuers`: List of allowed OIDC issuers +- `authorizationAudiences`: List of allowed audiences + +### 4. Frontend Integration +Vue.js frontend components: +- OIDC login flow with multiple providers (Okta, Keycloak, Google, Microsoft) +- Automatic token handling and refresh +- Navigation after successful authentication +- Error handling and user feedback +- State management with localStorage persistence +- **Conditional display of login buttons based on enabled providers** + +## Conditional OIDC Button Display + +The frontend login page (`structures-frontend-next/src/pages/login/Login.vue`) automatically shows/hides OIDC login buttons based on the enabled configuration properties. + +### How It Works + +1. **Provider Configuration**: Each OIDC provider has an `enabled` flag in the configuration +2. **Environment Variables**: The enabled flags are controlled by environment variables +3. **Conditional Rendering**: The login page checks these flags and only shows enabled providers +4. **Dynamic UI**: The "OR" separator only appears if at least one OIDC provider is enabled + +### Configuration + +**Environment Variables for Frontend:** +```bash +# Enable/disable specific providers +VITE_OIDC_OKTA_ENABLED=true +VITE_OIDC_KEYCLOAK_ENABLED=true +VITE_OIDC_GOOGLE_ENABLED=false +VITE_OIDC_MICROSOFT_ENABLED=false +VITE_OIDC_GITHUB_ENABLED=false +VITE_OIDC_CUSTOM_ENABLED=false +``` + +**Frontend Logic:** +```typescript +// Check if a specific provider is enabled +isProviderEnabled(provider: OidcProvider): boolean { + const config = getProviderConfig(provider); + return config.enabled; +} + +// Check if any OIDC providers are enabled +get hasEnabledOidcProviders(): boolean { + const providers: OidcProvider[] = ['okta', 'keycloak', 'google', 'microsoft', 'github', 'custom']; + return providers.some(provider => this.isProviderEnabled(provider)); +} +``` + +### Example Scenarios + +**Scenario 1: Only Okta Enabled** +- Shows: Username/Password form + "Continue with Okta" button +- Hides: All other OIDC buttons +- Shows: "OR" separator between forms + +**Scenario 2: Multiple Providers Enabled** +- Shows: Username/Password form + enabled provider buttons +- Hides: Disabled provider buttons +- Shows: "OR" separator between forms + +**Scenario 3: No OIDC Providers Enabled** +- Shows: Only Username/Password form +- Hides: All OIDC buttons and "OR" separator + +### Benefits + +- **Clean UI**: Only shows relevant login options +- **Easy Configuration**: Control via environment variables +- **Flexible Deployment**: Different providers for different environments +- **User Experience**: Reduces confusion by hiding unavailable options + +## Configuration + +### Application Properties + +Add the following configuration to your `application.yml`: + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://your-oidc-provider.com" + - "https://keycloak.your-domain.com/auth/realms/your-realm" + authorization-audiences: + - "your-application-client-id" + - "your-api-audience" +``` + +### Example Configurations + +#### Okta (Tested and Verified) +**⚠️ IMPORTANT**: Okta access tokens MUST include an email claim. See [Okta Configuration Guide](./okta.md) for detailed setup instructions. + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://dev-39125344.okta.com/oauth2/default" + authorization-audiences: + - "0oaowrlsm5Ua1vWD85d7" +``` + +**Frontend Configuration (structures-frontend-next):** +```typescript +// In OidcConfiguration.ts +okta: { + client_id: '0oaowrlsm5Ua1vWD85d7', + client_secret: '', + authority: 'https://dev-39125344.okta.com/oauth2/default', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + loadUserInfo: true, + scope: 'openid profile email', // email scope is REQUIRED + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + metadata: { + authorization_endpoint: 'https://dev-39125344.okta.com/oauth2/default/v1/authorize', + token_endpoint: 'https://dev-39125344.okta.com/oauth2/default/v1/token', + userinfo_endpoint: 'https://dev-39125344.okta.com/oauth2/default/v1/userinfo', + end_session_endpoint: 'https://dev-39125344.okta.com/oauth2/default/v1/logout', + jwks_uri: 'https://dev-39125344.okta.com/oauth2/default/v1/keys' + } +} +``` + +**For complete Okta setup instructions, see [Okta Configuration Guide](./okta.md)** + +#### Keycloak (Tested and Verified) +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "http://localhost:8888/auth/realms/master" + authorization-audiences: + - "structures-client" +``` + +**Frontend Configuration (structures-frontend-next):** +```typescript +// In OidcConfiguration.ts +keycloak: { + client_id: 'structures-client', + client_secret: '', + authority: 'http://localhost:8888/auth/realms/master', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + loadUserInfo: true, + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + // No explicit metadata - uses automatic discovery +} +``` + +**Docker Compose Setup:** +```bash +# Start Keycloak with PostgreSQL and auto-bootstrap +docker compose -f docker-compose/compose.keycloak.yml up -d keycloak-db keycloak keycloak-setup + +# Optionally wait for bootstrapper to complete +docker wait keycloak-setup +``` + +#### Auth0 +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://your-tenant.auth0.com/" + authorization-audiences: + - "https://your-api-identifier" +``` + +#### Azure AD +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://login.microsoftonline.com/your-tenant-id/v2.0" + authorization-audiences: + - "your-application-client-id" +``` + +## Social Login Configuration + +### Google OAuth2 + +#### 1. Google Cloud Console Setup + +1. **Create a Google Cloud Project:** + - Go to [Google Cloud Console](https://console.cloud.google.com/) + - Create a new project or select an existing one + +2. **Enable Google+ API:** + - Go to "APIs & Services" > "Library" + - Search for "Google+ API" and enable it + +3. **Create OAuth 2.0 Credentials:** + - Go to "APIs & Services" > "Credentials" + - Click "Create Credentials" > "OAuth 2.0 Client IDs" + - Choose "Web application" + - Add authorized redirect URIs: + - `http://localhost:5173/login` (development) + - `https://your-domain.com/login` (production) + +4. **Get Client Information:** + - Copy the Client ID and Client Secret + - Note the authorized redirect URIs + +#### 2. Frontend Configuration + +**Environment Variables:** +```bash +VITE_OIDC_GOOGLE_ENABLED=true +VITE_GOOGLE_CLIENT_ID=your-google-client-id +VITE_GOOGLE_AUTHORITY=https://accounts.google.com +VITE_GOOGLE_REDIRECT_URI=http://localhost:5173/login +VITE_GOOGLE_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_GOOGLE_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +**Frontend Configuration:** +```typescript +google: { + enabled: true, + client_id: 'your-google-client-id', + client_secret: '', + authority: 'https://accounts.google.com', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + loadUserInfo: true, + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + metadata: { + authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + token_endpoint: 'https://oauth2.googleapis.com/token', + userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo', + jwks_uri: 'https://www.googleapis.com/oauth2/v3/certs', + }, +} +``` + +#### 3. Backend Configuration + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://accounts.google.com" + authorization-audiences: + - "your-google-client-id" +``` + +### Microsoft Azure AD + +#### 1. Azure Portal Setup + +1. **Register an Application:** + - Go to [Azure Portal](https://portal.azure.com/) + - Navigate to "Azure Active Directory" > "App registrations" + - Click "New registration" + - Enter app name and redirect URI: `http://localhost:5173/login` + +2. **Configure Authentication:** + - Go to "Authentication" in your app registration + - Add platform: "Single-page application (SPA)" + - Add redirect URIs: + - `http://localhost:5173/login` + - `http://localhost:5173/login/silent-renew` + +3. **Get Client Information:** + - Copy the Application (client) ID + - Note the Directory (tenant) ID + +#### 2. Frontend Configuration + +**Environment Variables:** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-microsoft-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +**Frontend Configuration:** +```typescript +microsoft: { + enabled: true, + client_id: 'your-microsoft-client-id', + client_secret: '', + authority: 'https://login.microsoftonline.com/your-tenant-id/v2.0', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + loadUserInfo: true, + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + metadata: { + authorization_endpoint: 'https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/authorize', + token_endpoint: 'https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/token', + userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo', + end_session_endpoint: 'https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/logout', + jwks_uri: 'https://login.microsoftonline.com/your-tenant-id/discovery/v2.0/keys', + }, +} +``` + +#### 3. Backend Configuration + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://login.microsoftonline.com/your-tenant-id/v2.0" + authorization-audiences: + - "your-microsoft-client-id" +``` + +### GitHub OAuth + +#### 1. GitHub Developer Settings + +1. **Create OAuth App:** + - Go to [GitHub Developer Settings](https://github.com/settings/developers) + - Click "New OAuth App" + - Fill in the form: + - Application name: "Your App Name" + - Homepage URL: `http://localhost:5173` + - Authorization callback URL: `http://localhost:5173/login` + +2. **Get Client Information:** + - Copy the Client ID and Client Secret + - Note the callback URL + +#### 2. Frontend Configuration + +**Environment Variables:** +```bash +VITE_OIDC_GITHUB_ENABLED=true +VITE_GITHUB_CLIENT_ID=your-github-client-id +VITE_GITHUB_AUTHORITY=https://github.com +VITE_GITHUB_REDIRECT_URI=http://localhost:5173/login +VITE_GITHUB_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_GITHUB_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +**Frontend Configuration:** +```typescript +github: { + enabled: true, + client_id: 'your-github-client-id', + client_secret: '', + authority: 'https://github.com', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + loadUserInfo: true, + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + metadata: { + authorization_endpoint: 'https://github.com/login/oauth/authorize', + token_endpoint: 'https://github.com/login/oauth/access_token', + userinfo_endpoint: 'https://api.github.com/user', + }, +} +``` + +#### 3. Backend Configuration + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://github.com" + authorization-audiences: + - "your-github-client-id" +``` + +## Frontend State Management + +### State Flow +1. **Login Initiation**: + - Create base64-encoded state with provider and referer info + - Store in localStorage via oidc-client-ts library + - Redirect to OIDC provider + +2. **Callback Processing**: + - Check localStorage for stored state + - Determine which provider was used + - Use correct provider for callback + - Library handles state validation automatically + +### State Structure +```typescript +const stateObj = { + referer: string | null, // Original destination + provider: OidcProvider // Provider used for login +}; +``` + +### Provider Detection +The frontend automatically detects which provider was used by checking localStorage: +```typescript +const stateKey = `oidc.${state}`; +const storedState = localStorage.getItem(stateKey); +const sessionState = JSON.parse(storedState); +const stateObj = JSON.parse(atob(sessionState.data)); +const provider = stateObj.provider; +``` + +## JWT Claims Mapping + +The implementation extracts the following information from JWT claims: + +| JWT Claim | Participant Field | Description | +|-----------|------------------|-------------| +| `sub` | `id` | Subject identifier | +| `email` | `metadata.email` | User email (**REQUIRED**) | +| `name` | `metadata.name` | Full name | +| `preferred_username` | `metadata.name` | Username (fallback) | +| `tenant_id` or `tid` | `tenantId` | Tenant identifier | +| `roles`, `role`, `groups`, `realm_access.roles` | `roles` | User roles | + +### Email Claim Requirements + +**⚠️ CRITICAL**: The `email` claim is **REQUIRED** for authentication to succeed. The system follows this fallback order: + +1. **Primary**: Extract `email` claim from access token +2. **Fallback 1**: If no email claim, try `preferred_username` claim (must be valid email format) +3. **Fallback 2**: If no preferred_username, try `sub` claim (must be valid email format) +4. **Failure**: If none exist or none are valid emails, authentication fails + +**Why This Matters:** +- Email is used for user identification and tenant routing +- Without email information, user sessions cannot be created +- This is a security requirement, not optional + +**Supported Claims (in priority order):** +- **`email`**: Standard OIDC email claim +- **`preferred_username`**: Standard OIDC preferred username claim (often email format) +- **`sub`**: Standard OIDC subject claim (if it's an email format) +- **`upn`**: Microsoft-specific User Principal Name claim +- **`unique_name`**: Microsoft-specific legacy claim + +**Provider-Specific Notes:** +- **Okta**: Must configure access token to include email claim. See [Okta Configuration Guide](./okta.md) +- **Microsoft**: Usually includes email in access tokens, supports upn and unique_name +- **Keycloak**: Configurable, but typically minimal access tokens +- **Google**: Minimal access tokens, profile data via UserInfo endpoint + +### JSON Path Extraction + +The OIDC service now supports extracting values from nested JWT claims using dot-separated JSON paths. This allows you to access deeply nested claim values like `realm_access.roles` or `app_metadata.tenant.id`. + +**Configuration Example:** +```yaml +oidc-security-service: + oidc-providers: + - provider: "keycloak" + roles-claim-path: "realm_access.roles" # Extract from nested path + # ... other configuration +``` + +**Supported Path Formats:** +- Simple: `"email"`, `"name"` +- Nested: `"realm_access.roles"`, `"groups.department"` +- Deep: `"user.profile.preferences.theme"` + +For detailed information about JSON path extraction, see [JSON_PATH_EXTRACTION_EXAMPLE.md](./JSON_PATH_EXTRACTION_EXAMPLE.md). + +## Caching Strategy + +### JWKS Key Cache +- **TTL**: 1 hour +- **Max Size**: 100 keys +- **Purpose**: Cache individual public keys by issuer and key ID + +### Well-known Configuration Cache +- **TTL**: 24 hours +- **Max Size**: 10 configurations +- **Purpose**: Cache OIDC provider discovery documents + +## Security Features + +### 1. Token Validation +- Verifies JWT signature using public keys from JWKS +- Validates token expiration +- Checks issuer against allowed list +- Validates audience against allowed list (supports multiple audiences) + +### 2. State Management +- Base64 encoding for state parameters +- localStorage persistence for state tracking +- Automatic CSRF protection via oidc-client-ts +- Provider detection from stored state + +### 3. Error Handling +- Graceful handling of invalid tokens +- Comprehensive logging for debugging +- No sensitive information in error messages + +### 4. Cache Security +- Automatic cache expiration +- Size limits to prevent memory issues +- Secure key storage + +### 5. Frontend Security +- Secure token storage in cookies +- Automatic token refresh +- Proper logout and session cleanup + +## Usage Examples + +### Backend API Request +```bash +curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \ + https://your-api.com/endpoint +``` + +### Frontend Integration +```javascript +// Automatic OIDC login flow +const userManager = createUserManager('keycloak'); +await userManager.signinRedirect(); + +// Handle callback +const user = await userManager.signinRedirectCallback(); +await userState.handleOidcLogin(user); +``` + +### JavaScript Client +```javascript +// Set authorization header with JWT token +const headers = { + 'Authorization': `Bearer ${jwtToken}`, + 'Content-Type': 'application/json' +}; + +fetch('/api/endpoint', { headers }) + .then(response => response.json()) + .then(data => console.log(data)); +``` + +## Testing + +### Unit Tests +Run the test suite to validate the implementation: + +```bash +# Run all OIDC tests +./gradlew test --tests "*Oidc*" + +# Run specific test classes +./gradlew test --tests OidcAuthVerifierTest +./gradlew test --tests JwksServiceTest +``` + +### Integration Testing +For integration testing, you can: + +1. Use a test OIDC provider (e.g., Okta test instance) +2. Generate test JWT tokens +3. Mock the JWKS service for controlled testing + +### Test Configuration +```yaml +# Test configuration +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://test-issuer.com" + authorization-audiences: + - "test-audience" +``` + +### Frontend Testing +```bash +# Start the frontend development server +cd structures-frontend-next +npm run dev + +# Test OIDC login flow +# 1. Navigate to /login +# 2. Click "Continue with Keycloak" or "Continue with Okta" +# 3. Complete authentication +# 4. Verify navigation to /applications +``` + +### Keycloak Local Testing +```bash +# Start Keycloak and bootstrap +docker compose -f docker-compose/compose.keycloak.yml up -d keycloak-db keycloak keycloak-setup + +# Wait for bootstrap to complete +docker wait keycloak-setup + +# Test with test user +# Username: testuser@example.com +# Password: password123 +``` + +## Troubleshooting + +### Common Issues + +1. **"No authorization header found"** + - Ensure the Authorization header is present + - Check header name case sensitivity + +2. **"Invalid issuer"** + - Verify the issuer in your JWT token matches allowed issuers + - Check issuer URL format + +3. **"Invalid audience"** + - Ensure the audience in your JWT token matches allowed audiences + - Verify client ID configuration + +4. **"JWT token verification failed"** + - Check JWT token format + - Verify token signature + - Ensure token is not expired + +5. **"Navigation not working after login"** + - Check browser console for debug logs + - Verify router configuration + - Ensure CONTINUUM_UI is properly initialized + +6. **"Authority mismatch on settings vs. signin state"** + - Clear localStorage and try again + - Check provider configuration + - Verify automatic discovery is working + +### Social Login Specific Issues + +#### Google +- **"Invalid client"**: Check that redirect URI matches exactly in Google Console +- **"Access denied"**: Ensure Google+ API is enabled +- **"Invalid audience"**: Verify client ID in backend configuration + +#### Microsoft +- **"AADSTS50011"**: Redirect URI mismatch - check Azure app registration +- **"AADSTS70002"**: Invalid client ID or secret +- **"AADSTS90002"**: Tenant not found - verify tenant ID + +#### GitHub +- **"Bad verification code"**: Check client secret and redirect URI +- **"Application not found"**: Verify OAuth app configuration +- **"Invalid state"**: Check state parameter handling + +### Debug Logging + +Enable debug logging for troubleshooting: + +```yaml +logging: + level: + org.kinotic.structures.internal.config: DEBUG +``` + +### Frontend Debugging +```javascript +// Check authentication state +console.log('Is authenticated:', userState.isAuthenticated()); + +// Check navigation +console.log('Current route:', router.currentRoute.value.path); + +// Check stored state +console.log('Stored state:', localStorage.getItem('oidc.state.your-client-id')); +``` + +## Performance Considerations + +1. **Cache Tuning**: Adjust cache TTL and size based on your OIDC provider +2. **Network Timeouts**: Configure appropriate timeouts for JWKS fetching +3. **Memory Usage**: Monitor cache memory usage in production +4. **Concurrent Requests**: The implementation is thread-safe and handles concurrent requests + +## Security Best Practices + +1. **HTTPS Only**: Always use HTTPS in production +2. **Token Validation**: Never skip token validation +3. **Audience Validation**: Always validate audience claims +4. **Issuer Validation**: Strictly validate issuer claims +5. **Token Expiration**: Ensure tokens have reasonable expiration times +6. **Key Rotation**: Monitor for key rotation events from your OIDC provider +7. **State Management**: Use proper state validation to prevent CSRF attacks + +## Migration from Other Authentication + +To migrate from other authentication methods: + +1. Configure OIDC properties +2. Enable OIDC authentication +3. Update client applications to send JWT tokens +4. Test thoroughly in staging environment +5. Deploy to production + +## Support + +For issues or questions: +1. Check the troubleshooting section +2. Review application logs +3. Verify OIDC provider configuration +4. Test with a known valid JWT token +5. Check browser console for frontend issues \ No newline at end of file diff --git a/structures-auth/oidc-docs/README_KEYCLOAK_SETUP.md b/structures-auth/oidc-docs/README_KEYCLOAK_SETUP.md new file mode 100644 index 000000000..56d54aad3 --- /dev/null +++ b/structures-auth/oidc-docs/README_KEYCLOAK_SETUP.md @@ -0,0 +1,362 @@ +# Keycloak Identity Provider Setup for Structures + +This guide provides a complete setup for using Keycloak as an identity provider with the Structures application. + +## Overview + +The setup includes: +- **Keycloak 24.0.2** with PostgreSQL database +- **Pre-configured client** for the Structures frontend +- **Test user** for immediate testing +- **Automatic setup script** for easy configuration +- **Frontend integration** with Vue.js login page +- **Backend integration** with OIDC authentication + +## Quick Start + +### 1. Prerequisites + +Ensure you have the following installed: +- Docker and Docker Compose +- `jq` (JSON processor) +- Node.js and npm (for frontend) + +```bash +# Install jq if not already installed +brew install jq # macOS +# or +sudo apt-get install jq # Ubuntu/Debian +``` + +### 2. Start the Complete Stack (recommended) + +```bash +# Make scripts executable +chmod +x docker-compose/*.sh + +# Start everything with one command (includes Keycloak bootstrap) +./docker-compose/start-with-keycloak.sh + +# Or manually: +cd structures/docker-compose && \ +docker compose -f compose.yml -f compose.ek-m4.override.yml -f compose.gen-schemas.yml up -d && \ +docker compose -f compose.keycloak.yml up -d keycloak-db keycloak keycloak-setup +``` + +This script will: +- Create the Docker network +- Start Elasticsearch and other services +- Start Keycloak with PostgreSQL +- Wait for all services to be ready +- Automatically configure Keycloak with the Structures client +- Create a test user + +### 3. Start the Frontend + +```bash +cd structures-frontend-next +npm install +npm run dev +``` + +### 4. Test the Setup + +1. Navigate to `http://localhost:5173/login` +2. Click "Continue with Keycloak" +3. Login with test credentials: + - Username: `testuser@example.com` + - Password: `password123` + +## Manual Setup (Alternative) + +If you prefer to set up manually: + +### 1. Start Keycloak Only + +```bash +# Create network +docker network create structures-network + +# Start Keycloak (DB, server, and one-shot bootstrapper) +docker compose -f docker-compose/compose.keycloak.yml up -d keycloak-db keycloak keycloak-setup + +# Optionally wait for bootstrapper to complete +docker wait keycloak-setup +``` + +### 2. Start Backend + +```bash +./gradlew :structures-server:bootRun +``` + +### 3. Start Frontend + +```bash +cd structures-frontend-next +npm run dev +``` + +## Configuration Details + +### Keycloak Client Configuration + +The setup creates a client with these settings: + +- **Client ID**: `structures-client` +- **Client Type**: Public Client (for SPA) +- **Access Type**: Public +- **Standard Flow**: Enabled +- **Direct Access Grants**: Disabled +- **Service Accounts**: Disabled + +### Redirect URIs + +- `http://localhost:5173/login` - Main redirect URI +- `http://localhost:5173/login/silent-renew` - Silent token renewal + +### Web Origins + +- `http://localhost:5173` - Frontend origin + +### Protocol Mappers + +The client includes these protocol mappers: + +1. **Email**: Maps user email to `email` claim +2. **Given Name**: Maps first name to `given_name` claim +3. **Family Name**: Maps last name to `family_name` claim +4. **Preferred Username**: Maps username to `preferred_username` claim + +## Service URLs + +| Service | URL | Description | +|---------|-----|-------------| +| Structures Server | http://localhost:9090 | Main application | +| Structures API | http://localhost:8080 | REST API | +| GraphQL | http://localhost:4000 | GraphQL endpoint | +| Keycloak | http://localhost:8888/auth | Identity provider | +| Keycloak Admin | http://localhost:8888/auth/admin | Admin console | +| Frontend | http://localhost:5173 | Vue.js application | + +## Admin Credentials + +### Keycloak Admin Console +- **URL**: http://localhost:8888/auth/admin +- **Username**: `admin` +- **Password**: `admin` + +### Test User +- **Username**: `testuser@example.com` +- **Email**: `testuser@example.com` +- **Password**: `password123` +- **First Name**: Test +- **Last Name**: User + +## Frontend Configuration + +The frontend is configured via runtime JSON. Update `structures-frontend-next/public/app-config.json` (or serve `/config/app-config.json`). See `structures-frontend-next/CONFIGURATION.md` for full details. Example Keycloak section: + +```json +{ + "oidc": { + "keycloak": { + "enabled": true, + "client_id": "structures-client", + "authority": "http://localhost:8888/auth/realms/master", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + } + } +} +``` + +## Backend Configuration + +The backend is configured in `structures-server/src/main/resources/application-development.yml`: + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://dev-39125344.okta.com/oauth2/default" + - "http://localhost:8888/auth/realms/master" + authorization-audiences: + - "api://default" + - "structures-client" +``` + +## Testing the Integration + +### 1. API Testing with curl + +```bash +# Get a token from Keycloak +TOKEN=$(curl -s -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=testuser@example.com&password=password123&grant_type=password&client_id=structures-client" \ + http://localhost:8888/auth/realms/master/protocol/openid-connect/token | \ + jq -r '.access_token') + +# Use the token to access the API +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:9090/health/ +``` + +### 2. Frontend Testing + +1. Open `http://localhost:5173/login` +2. Click "Continue with Keycloak" +3. Login with `testuser@example.com` / `password123` +4. Verify you're redirected to the applications page + +### 3. Backend Health Check + +```bash +# Test backend health +curl http://localhost:9090/health/ + +# Test with authentication +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:9090/health/ +``` + +## Troubleshooting + +### Common Issues + +1. **Keycloak Not Starting** + ```bash + # Check logs + docker compose -f docker-compose/compose.keycloak.yml logs keycloak + + # Check if port 8888 is available + lsof -i :8888 + ``` + +2. **Bootstrapper Fails** + ```bash + # Show bootstrap container logs + docker logs keycloak-setup | tail -n 200 + + # Check if Keycloak is ready + curl -sf http://localhost:8888/auth/health/ready + ``` + +3. **Frontend Connection Issues** + - Check browser console for CORS errors + - Verify Keycloak is running on port 8080 + - Ensure redirect URIs match exactly + +4. **Backend Authentication Issues** + ```bash + # Check backend logs + ./gradlew :structures-server:bootRun + + # Verify issuer URL + curl http://localhost:8888/auth/realms/master/.well-known/openid-configuration + ``` + +5. **Network Issues** + ```bash + # Check if network exists + docker network ls | grep structures-network + + # Create network if missing + docker network create structures-network + ``` + +### Debug Logging + +Enable debug logging for troubleshooting: + +```yaml +logging: + level: + org.kinotic.structures.internal.config: DEBUG + org.keycloak: DEBUG +``` + +### Manual Client Creation + +If the setup script fails, manually create the client: + +1. Login to Keycloak admin console +2. Navigate to Clients → Create +3. Set Client ID to `structures-client` +4. Set Client Protocol to `openid-connect` +5. Set Access Type to `public` +6. Add redirect URIs: + - `http://localhost:5173/login` + - `http://localhost:5173/login/silent-renew` +7. Add web origins: `http://localhost:5173` +8. Configure protocol mappers for email, name, etc. + +## Production Considerations + +### Security +- Change default admin password +- Use HTTPS for all endpoints +- Configure proper CORS settings +- Use strong passwords for users + +### Database +- Use external PostgreSQL database +- Configure database backups +- Monitor database performance + +### Scaling +- Configure Keycloak clustering +- Use load balancer for high availability +- Monitor resource usage + +### Monitoring +- Enable Keycloak metrics +- Configure logging aggregation +- Set up health checks + +## Customization + +### Using a Custom Realm + +1. Create a new realm in Keycloak admin console +2. Update the authority URL in frontend configuration +3. Update the issuer URL in backend configuration +4. Create the client in the new realm + +### Adding More Users + +1. Login to Keycloak admin console +2. Navigate to Users → Add User +3. Fill in user details +4. Set credentials +5. Assign roles if needed + +### Adding Roles + +1. Login to Keycloak admin console +2. Navigate to Roles → Add Role +3. Create roles as needed +4. Assign roles to users + +## Support + +For issues or questions: +1. Check the troubleshooting section +2. Review application logs +3. Verify Keycloak configuration +4. Test with a known valid JWT token +5. Check browser console for frontend issues + +## Files Overview + +| File | Purpose | +|------|---------| +| `docker-compose/compose-keycloak.yml` | Keycloak and PostgreSQL services | +| `docker-compose/setup-keycloak.sh` | Automated Keycloak configuration | +| `docker-compose/start-with-keycloak.sh` | Complete stack startup script | +| (removed) `docker-compose/KEYCLOAK_README.md` | Consolidated into this guide | +| `structures-frontend-next/src/pages/login/OidcConfiguration.ts` | Frontend OIDC configuration | +| `structures-server/src/main/resources/application-development.yml` | Backend OIDC configuration | \ No newline at end of file diff --git a/structures-auth/oidc-docs/entra/MICROSOFT_AADSTS901002_TROUBLESHOOTING.md b/structures-auth/oidc-docs/entra/MICROSOFT_AADSTS901002_TROUBLESHOOTING.md new file mode 100644 index 000000000..742245010 --- /dev/null +++ b/structures-auth/oidc-docs/entra/MICROSOFT_AADSTS901002_TROUBLESHOOTING.md @@ -0,0 +1,217 @@ +# Microsoft OIDC: AADSTS901002 Error Troubleshooting + +## Error Description + +``` +AADSTS901002: The 'resource' request parameter is not supported. +``` + +## Root Cause + +This error occurs because you're trying to use the `resource` parameter with Azure AD v2.0, which doesn't support this parameter. Azure AD v2.0 uses the `scope` parameter instead. + +## Solution + +### Option 1: Use Scope Parameter (Recommended) + +**Update your OIDC configuration to use the scope parameter:** + +```typescript +microsoft: { + enabled: true, + client_id: 'your-application-client-id', + authority: 'https://login.microsoftonline.com/your-tenant-id/v2.0', + // Use scope instead of resource parameter + scope: 'openid profile email your-application-client-id' +} +``` + +**Environment Variables:** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_RESOURCE=your-application-client-id # This will be used in scope +``` + +### Option 2: Use Default Graph API Audience + +If you don't need a custom audience, use the default Microsoft Graph API audience: + +**Frontend Configuration:** +```bash +# Remove VITE_MICROSOFT_RESOURCE or set it to empty +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +# VITE_MICROSOFT_RESOURCE= # Don't set this +``` + +**Backend Configuration:** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "00000003-0000-0000-c000-000000000000" # Microsoft Graph API +``` + +## Azure AD v1.0 vs v2.0 Differences + +### Azure AD v1.0 (Legacy) +- **Resource Parameter**: `resource=your-app-id` +- **Scope Parameter**: `scope=openid profile email` +- **Endpoint**: `https://login.microsoftonline.com/tenant-id/oauth2/authorize` + +### Azure AD v2.0 (Current) +- **Resource Parameter**: Not supported +- **Scope Parameter**: `scope=openid profile email your-app-id` +- **Endpoint**: `https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize` + +## Implementation Steps + +### Step 1: Update Frontend Configuration + +1. **Set the environment variable:** + ```bash + VITE_MICROSOFT_RESOURCE=your-application-client-id + ``` + +2. **The OIDC configuration will automatically use the scope parameter:** + ```typescript + // This is handled automatically by the configuration + scope: 'openid profile email your-application-client-id' + ``` + +### Step 2: Update Backend Configuration + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "your-application-client-id" +``` + +### Step 3: Test the Configuration + +1. **Clear browser cache and cookies** +2. **Restart your frontend application** +3. **Test the Microsoft login flow** +4. **Check the JWT token audience** + +## Expected JWT Token Structure + +With the scope parameter configuration, your JWT tokens should have: + +```json +{ + "aud": "your-application-client-id", + "iss": "https://sts.windows.net", + "sub": "user-id", + "exp": 1640995200, + "scp": "openid profile email your-application-client-id" +} +``` + +## Troubleshooting + +### Issue: Still Getting AADSTS901002 Error +**Cause**: The resource parameter is still being sent +**Solution**: +1. Ensure you're using the updated OIDC configuration +2. Clear browser cache and cookies +3. Check that `VITE_MICROSOFT_RESOURCE` is set correctly + +### Issue: "Invalid scope" Error +**Cause**: The scope format is incorrect +**Solution**: +1. Ensure the scope includes `openid profile email` +2. Add your application's client ID to the scope +3. Check for typos in the client ID + +### Issue: "Invalid audience" Error +**Cause**: Backend configuration doesn't match +**Solution**: +1. Update `authorization-audiences` to include your client ID +2. Restart your backend application +3. Test the authentication flow + +## Alternative: Use Graph API Audience + +If you prefer to use the standard Microsoft Graph API audience: + +### Frontend Configuration +```bash +# Don't set VITE_MICROSOFT_RESOURCE +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +``` + +### Backend Configuration +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "00000003-0000-0000-c000-000000000000" # Microsoft Graph API +``` + +## Security Considerations + +### 1. Scope Security +- Only include necessary scopes +- Always include `openid` for OIDC compliance +- Include `profile email` for user information +- Add your application's client ID for custom audience + +### 2. Audience Validation +- Always validate the audience claim +- Use exact string matching (case-sensitive) +- Log authentication events for monitoring + +### 3. Token Security +- Validate token expiration +- Check token signature +- Verify issuer claims +- Monitor for suspicious activity + +## Example Complete Configuration + +### Frontend (.env.development) +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0 +VITE_MICROSOFT_RESOURCE=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +### Backend (application-dev.yml) +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "c81b2096-7781-4eb1-a2f6-42371330add6" +``` + +### Expected OIDC Request +``` +GET /oauth2/v2.0/authorize? + client_id=c81b2096-7781-4eb1-a2f6-42371330add6& + scope=openid%20profile%20email%20c81b2096-7781-4eb1-a2f6-42371330add6& + response_type=code& + redirect_uri=http://localhost:5173/login +``` \ No newline at end of file diff --git a/structures-auth/oidc-docs/entra/MICROSOFT_AUDIENCE_CONFIGURATION.md b/structures-auth/oidc-docs/entra/MICROSOFT_AUDIENCE_CONFIGURATION.md new file mode 100644 index 000000000..0e99cc2cc --- /dev/null +++ b/structures-auth/oidc-docs/entra/MICROSOFT_AUDIENCE_CONFIGURATION.md @@ -0,0 +1,400 @@ +# Microsoft OIDC Audience Configuration Guide + +## Overview + +The audience (aud) claim in JWT tokens is a critical security parameter that ensures tokens are intended for your specific application. For Microsoft Azure AD, the audience configuration must match between your frontend OIDC client and backend token validation. + +**Important Note**: Microsoft Azure AD uses different URLs for authentication and token issuance: +- **Authentication**: `https://login.microsoftonline.com` (where users log in) +- **Token Issuer**: `https://sts.windows.net` (where tokens are actually issued) + +## Audience Configuration Options + +### Option 1: Use Microsoft Graph API Audience (Default/Standard) + +Microsoft OIDC clients typically request tokens for **Microsoft Graph API** by default: + +- **Graph API Audience**: `00000003-0000-0000-c000-000000000000` +- **Purpose**: Access to Microsoft Graph API for user information +- **Standard**: This is the most common audience for Microsoft OIDC tokens + +**Backend Configuration:** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "00000003-0000-0000-c000-000000000000" # Microsoft Graph API +``` + +### Option 2: Use Your Application's Client ID as Audience + +If you want to use your application's client ID as the audience, you need to configure the OIDC client to request tokens specifically for your application. + +#### Frontend Configuration + +**Update OIDC Configuration:** +```typescript +microsoft: { + enabled: true, + client_id: 'your-application-client-id', + authority: 'https://login.microsoftonline.com/your-tenant-id/v2.0', + // Add custom scope to request tokens for your app (Azure AD v2.0) + scope: 'openid profile email your-application-client-id' +} +``` + +**Environment Variables:** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_RESOURCE=your-application-client-id # Add this +``` + +**Note**: Azure AD v2.0 uses the `scope` parameter instead of the `resource` parameter. The scope includes your application's client ID to request tokens with your application as the audience. + +#### Backend Configuration + +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "your-application-client-id" # Your app's client ID +``` + +#### JWT Token Structure (Option 2) + +```json +{ + "aud": "your-application-client-id", // Your app's client ID + "iss": "https://sts.windows.net", + "sub": "user-id", + "exp": 1640995200, + "tid": "your-tenant-id" +} +``` + +## Understanding Microsoft's Token Architecture + +### Authentication vs Token Issuance + +Microsoft Azure AD separates authentication from token issuance: + +1. **Authentication Endpoint**: `https://login.microsoftonline.com` + - Where users authenticate + - Used by OIDC client for login flow + - Handles user interaction + +2. **Token Issuer**: `https://sts.windows.net` + - Where JWT tokens are actually issued + - Appears in the `iss` claim of JWT tokens + - Used by backend for token validation + +### Standard Microsoft OIDC Audience + +Microsoft OIDC clients typically request tokens for **Microsoft Graph API** by default: + +- **Graph API Audience**: `00000003-0000-0000-c000-000000000000` +- **Purpose**: Access to Microsoft Graph API for user information +- **Standard**: This is the most common audience for Microsoft OIDC tokens + +### JWT Token Structure + +```json +{ + "aud": "00000003-0000-0000-c000-000000000000", // Microsoft Graph API + "iss": "https://sts.windows.net", // Note: Not login.microsoftonline.com + "sub": "user-id", + "exp": 1640995200, + "tid": "your-tenant-id" +} +``` + +## Audience Configuration + +### 1. Frontend Configuration (OIDC Client) + +The frontend OIDC client uses the authentication endpoint for login, but the JWT token will have Microsoft Graph API as the audience. + +**Environment Variables:** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +``` + +**OIDC Configuration:** +```typescript +microsoft: { + enabled: true, + client_id: 'your-application-client-id', // Used for authentication + authority: 'https://login.microsoftonline.com/your-tenant-id/v2.0', // Authentication endpoint + // ... other configuration +} +``` + +### 2. Backend Configuration (Token Validation) + +The backend must be configured to accept the **Microsoft Graph API audience** from the JWT token. + +**Application Properties:** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" # The actual issuer in JWT tokens + authorization-audiences: + - "00000003-0000-0000-c000-000000000000" # Microsoft Graph API (standard) + - "your-application-client-id" # Your app (if configured) +``` + +## Step-by-Step Configuration + +### Step 1: Get Your Azure AD Application Information + +1. **Go to Azure Portal:** + - Navigate to [Azure Portal](https://portal.azure.com/) + - Go to "Azure Active Directory" > "App registrations" + - Select your application + +2. **Copy Application (Client) ID:** + - This is your audience value + - Example: `c81b2096-7781-4eb1-a2f6-42371330add6` + +3. **Copy Directory (Tenant) ID:** + - This is used in the authentication endpoint + - Example: `12345678-1234-1234-1234-123456789012` + +### Step 2: Configure Frontend Environment Variables + +**Development Environment (.env.development):** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +**Production Environment (.env.production):** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0 +VITE_MICROSOFT_REDIRECT_URI=https://your-domain.com/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=https://your-domain.com +VITE_MICROSOFT_SILENT_REDIRECT_URI=https://your-domain.com/login/silent-renew +``` + +### Step 3: Configure Backend Application Properties + +**Development Environment (application-dev.yml):** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" # The actual JWT issuer + authorization-audiences: + - "c81b2096-7781-4eb1-a2f6-42371330add6" +``` + +**Production Environment (application-prod.yml):** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" # The actual JWT issuer + authorization-audiences: + - "c81b2096-7781-4eb1-a2f6-42371330add6" +``` + +## Audience Validation Process + +### 1. Token Request (Frontend) +When the frontend requests a token from Microsoft: +``` +Request: GET /oauth2/v2.0/authorize +Parameters: + client_id: c81b2096-7781-4eb1-a2f6-42371330add6 + scope: openid profile email + response_type: code +``` + +### 2. Token Response (Microsoft) +Microsoft issues a JWT token with the audience claim: +```json +{ + "aud": "c81b2096-7781-4eb1-a2f6-42371330add6", + "iss": "https://sts.windows.net", // Note: Not login.microsoftonline.com + "sub": "user-id", + "exp": 1640995200 +} +``` + +### 3. Token Validation (Backend) +The backend validates the token: +```java +// OidcAuthVerifier validates: +// 1. Issuer matches allowed-issuers (https://sts.windows.net) +// 2. Audience matches authorization-audiences +// 3. Token signature is valid +// 4. Token is not expired +``` + +## Common Audience Configuration Issues + +### Issue 1: "Invalid issuer" Error +**Symptoms:** +- Backend logs show "Invalid issuer" error +- JWT token issuer is `https://sts.windows.net` but backend expects `https://login.microsoftonline.com` + +**Solution:** +- Update `allowed-issuers` to include `https://sts.windows.net` +- The authentication endpoint and token issuer are different + +### Issue 2: "Invalid audience" Error +**Symptoms:** +- Backend logs show "Invalid audience" error +- Frontend login succeeds but API calls fail + +**Solution:** +- Ensure `authorization-audiences` in backend matches `client_id` in frontend +- Check for typos in the client ID +- Verify the audience is exactly the same (case-sensitive) + +### Issue 3: Multiple Audiences +**Scenario:** Your application needs to accept tokens for multiple client IDs + +**Backend Configuration:** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "client-id-1" + - "client-id-2" + - "client-id-3" +``` + +## Testing Audience Configuration + +### 1. Verify Frontend Configuration +```javascript +// Check browser console for OIDC configuration +console.log('Microsoft OIDC Config:', { + client_id: import.meta.env.VITE_MICROSOFT_CLIENT_ID, + authority: import.meta.env.VITE_MICROSOFT_AUTHORITY +}); +``` + +### 2. Verify Backend Configuration +```bash +# Check application logs for OIDC configuration +grep "authorization-audiences" application.log +``` + +### 3. Test Token Validation +1. Complete Microsoft login flow +2. Check browser network tab for token request +3. Decode JWT token to verify audience claim +4. Verify backend accepts the token + +### 4. JWT Token Inspection +```javascript +// Decode JWT token in browser console +const token = 'your-jwt-token'; +const payload = JSON.parse(atob(token.split('.')[1])); +console.log('Token Audience:', payload.aud); +console.log('Token Issuer:', payload.iss); // Should be https://sts.windows.net +``` + +## Security Best Practices + +### 1. Issuer Validation +- Always validate the issuer claim +- Use `https://sts.windows.net` for Microsoft tokens +- Never skip issuer validation + +### 2. Audience Validation +- Always validate the audience claim +- Never skip audience validation +- Use exact string matching (case-sensitive) + +### 3. Multiple Audiences +- Only include necessary audiences +- Remove unused audience entries +- Regularly audit audience configuration + +### 4. Environment Separation +- Use different client IDs for dev/staging/prod +- Never share client secrets between environments +- Use environment-specific configuration + +## Troubleshooting Checklist + +### Frontend Issues +- [ ] `VITE_MICROSOFT_CLIENT_ID` is set correctly +- [ ] `VITE_MICROSOFT_AUTHORITY` uses correct tenant ID +- [ ] Redirect URIs match Azure AD configuration +- [ ] Application is configured as SPA in Azure AD + +### Backend Issues +- [ ] `authorization-audiences` matches frontend `client_id` +- [ ] `allowed-issuers` includes `https://sts.windows.net` +- [ ] OIDC authentication is enabled +- [ ] Application properties are loaded correctly + +### Azure AD Issues +- [ ] Application is registered in Azure AD +- [ ] Authentication is configured for SPA +- [ ] Redirect URIs are configured correctly +- [ ] Application permissions are set appropriately + +## Example Complete Configuration + +### Frontend (.env.development) +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +### Backend (application-dev.yml) +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" # The actual JWT issuer + authorization-audiences: + - "c81b2096-7781-4eb1-a2f6-42371330add6" +``` + +### Azure AD App Registration +- **Application (client) ID**: `c81b2096-7781-4eb1-a2f6-42371330add6` +- **Directory (tenant) ID**: `12345678-1234-1234-1234-123456789012` +- **Redirect URIs**: `http://localhost:5173/login` +- **Platform**: Single-page application (SPA) + +## Key Differences Summary + +| Component | Frontend Configuration | Backend Configuration | JWT Token | +|-----------|----------------------|----------------------|-----------| +| **Authentication** | `https://login.microsoftonline.com` | N/A | N/A | +| **Token Issuer** | N/A | `https://sts.windows.net` | `"iss": "https://sts.windows.net"` | +| **Audience** | `client_id` | `authorization-audiences` | `"aud": "client-id"` | \ No newline at end of file diff --git a/structures-auth/oidc-docs/entra/MICROSOFT_CLIENT_ID_AUDIENCE.md b/structures-auth/oidc-docs/entra/MICROSOFT_CLIENT_ID_AUDIENCE.md new file mode 100644 index 000000000..70742ac91 --- /dev/null +++ b/structures-auth/oidc-docs/entra/MICROSOFT_CLIENT_ID_AUDIENCE.md @@ -0,0 +1,215 @@ +# Microsoft OIDC: Using Your Application's Client ID as Audience + +## Overview + +This guide explains how to configure Microsoft OIDC to use your application's client ID as the audience (`aud` claim) in JWT tokens instead of the default Microsoft Graph API audience. + +## Default vs Custom Audience + +### Default Behavior (Microsoft Graph API) +- **Audience**: `00000003-0000-0000-c000-000000000000` (Microsoft Graph API) +- **Purpose**: Access to Microsoft Graph API for user information +- **Configuration**: Standard OIDC client configuration + +### Custom Behavior (Your Application) +- **Audience**: Your application's client ID +- **Purpose**: Tokens specifically for your application +- **Configuration**: Requires additional OIDC parameters + +## Configuration Steps + +### Step 1: Frontend Configuration + +#### Environment Variables +```bash +# .env.development or .env.production +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_RESOURCE=your-application-client-id # Add this line +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +#### OIDC Configuration +The configuration automatically adds the custom scope when `VITE_MICROSOFT_RESOURCE` is set: + +```typescript +microsoft: { + enabled: true, + client_id: 'your-application-client-id', + authority: 'https://login.microsoftonline.com/your-tenant-id/v2.0', + // Automatically added when VITE_MICROSOFT_RESOURCE is set + scope: 'openid profile email your-application-client-id' +} +``` + +**Note**: Azure AD v2.0 uses the `scope` parameter instead of the `resource` parameter. The scope includes your application's client ID to request tokens with your application as the audience. + +### Step 2: Backend Configuration + +#### Application Properties +```yaml +# application-dev.yml or application-prod.yml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "your-application-client-id" # Your app's client ID +``` + +### Step 3: Azure AD App Registration + +#### 1. Configure Your Application +1. Go to [Azure Portal](https://portal.azure.com/) +2. Navigate to "Azure Active Directory" > "App registrations" +3. Select your application + +#### 2. Add API Permissions (Optional) +If you want to access Microsoft Graph API from your application: +1. Go to "API permissions" +2. Click "Add a permission" +3. Select "Microsoft Graph" +4. Choose appropriate permissions (e.g., "User.Read") +5. Click "Add permissions" + +#### 3. Configure Authentication +1. Go to "Authentication" +2. Add platform: "Single-page application (SPA)" +3. Add redirect URIs: + - `http://localhost:5173/login` (development) + - `https://your-domain.com/login` (production) +4. Under "Advanced settings": + - Set "Allow public client flows" to "Yes" +5. Save changes + +## JWT Token Structure + +With this configuration, your JWT tokens will have: + +```json +{ + "aud": "your-application-client-id", // Your app's client ID + "iss": "https://sts.windows.net", + "sub": "user-id", + "exp": 1640995200, + "tid": "your-tenant-id", + "scp": "openid profile email" // Scopes granted +} +``` + +## Testing the Configuration + +### 1. Verify Environment Variables +```bash +# Check that all variables are set +echo $VITE_OIDC_MICROSOFT_ENABLED +echo $VITE_MICROSOFT_CLIENT_ID +echo $VITE_MICROSOFT_AUTHORITY +echo $VITE_MICROSOFT_RESOURCE +``` + +### 2. Test Login Flow +1. Start your frontend application +2. Navigate to the login page +3. Click "Continue with Microsoft" +4. Complete the Microsoft login flow +5. Check the JWT token audience + +### 3. Inspect JWT Token +```javascript +// Decode JWT token in browser console +const token = 'your-jwt-token'; +const payload = JSON.parse(atob(token.split('.')[1])); +console.log('Token Audience:', payload.aud); // Should be your client ID +console.log('Token Issuer:', payload.iss); // Should be https://sts.windows.net +``` + +## Troubleshooting + +### Issue: Still Getting Graph API Audience +**Cause**: The resource parameter is not being sent correctly +**Solution**: +1. Verify `VITE_MICROSOFT_RESOURCE` is set to your client ID +2. Check browser network tab for the authorization request +3. Ensure the `resource` parameter is included in the request + +### Issue: "Invalid audience" Error +**Cause**: Backend configuration doesn't match frontend +**Solution**: +1. Ensure `authorization-audiences` includes your client ID +2. Check for typos in the client ID +3. Verify the client ID is exactly the same (case-sensitive) + +### Issue: "Invalid resource" Error +**Cause**: Azure AD doesn't recognize your application as a resource +**Solution**: +1. Ensure your application is properly registered in Azure AD +2. Check that the client ID is correct +3. Verify the application has the necessary permissions + +## Security Considerations + +### 1. Resource Parameter Security +- The resource parameter determines the audience +- Only use your own application's client ID +- Never use arbitrary values + +### 2. Audience Validation +- Always validate the audience claim +- Use exact string matching (case-sensitive) +- Log authentication events for monitoring + +### 3. Token Security +- Validate token expiration +- Check token signature +- Verify issuer claims +- Monitor for suspicious activity + +## Example Complete Configuration + +### Frontend (.env.development) +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0 +VITE_MICROSOFT_RESOURCE=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +### Backend (application-dev.yml) +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "c81b2096-7781-4eb1-a2f6-42371330add6" +``` + +### Expected JWT Token +```json +{ + "aud": "c81b2096-7781-4eb1-a2f6-42371330add6", + "iss": "https://sts.windows.net", + "sub": "user-id", + "exp": 1640995200, + "tid": "12345678-1234-1234-1234-123456789012" +} +``` + +## Comparison: Graph API vs Custom Audience + +| Aspect | Graph API Audience | Custom Audience | +|--------|-------------------|-----------------| +| **Audience** | `00000003-0000-0000-c000-000000000000` | Your client ID | +| **Configuration** | Standard OIDC | Requires resource parameter | +| **Use Case** | Access Graph API | Application-specific tokens | +| **Complexity** | Simple | Requires additional setup | +| **Security** | Standard Microsoft pattern | Custom application security | \ No newline at end of file diff --git a/structures-auth/oidc-docs/entra/MICROSOFT_OIDC_TROUBLESHOOTING.md b/structures-auth/oidc-docs/entra/MICROSOFT_OIDC_TROUBLESHOOTING.md new file mode 100644 index 000000000..c7430e93b --- /dev/null +++ b/structures-auth/oidc-docs/entra/MICROSOFT_OIDC_TROUBLESHOOTING.md @@ -0,0 +1,180 @@ +# Microsoft OIDC Troubleshooting Guide + +## Common Error: AADSTS50194 - Multi-tenant Configuration + +### Error Description +``` +AADSTS50194: Application 'c81b2096-7781-4eb1-a2f6-42371330add6'(structures-ui-test) is not configured as a multi-tenant application. Usage of the /common endpoint is not supported for such applications created after '10/15/2018'. Use a tenant-specific endpoint or configure the application to be multi-tenant. +``` + +### Root Cause +The Microsoft OIDC configuration is using the `/common` endpoint, but the Azure AD application is configured as single-tenant (not multi-tenant). + +### Solutions + +#### Option 1: Use Tenant-Specific Endpoint (Recommended) + +**Step 1: Get Your Tenant ID** +1. Go to [Azure Portal](https://portal.azure.com/) +2. Navigate to "Azure Active Directory" > "Overview" +3. Copy your "Tenant ID" (Directory ID) + +**Step 2: Update Environment Variables** +```bash +# Replace 'your-tenant-id' with your actual tenant ID +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +``` + +**Step 3: Update OIDC Configuration** +The configuration will automatically use the tenant-specific endpoint instead of `/common`. + +#### Option 2: Configure Application as Multi-tenant + +**Step 1: Update Azure AD App Registration** +1. Go to [Azure Portal](https://portal.azure.com/) +2. Navigate to "Azure Active Directory" > "App registrations" +3. Select your application +4. Go to "Authentication" in the left menu +5. Under "Advanced settings" > "Allow public client flows", set to "Yes" +6. Go to "Manifest" in the left menu +7. Find the `signInAudience` property and change it from `"AzureADMyOrg"` to `"AzureADandPersonalMicrosoftAccount"` or `"AzureADMultipleOrgs"` +8. Save the changes + +**Step 2: Keep Current Configuration** +```bash +# Keep using the /common endpoint +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +``` + +### Environment Variable Configuration + +#### For Single-Tenant (Recommended) +```bash +# .env.development or .env.production +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +#### For Multi-Tenant +```bash +# .env.development or .env.production +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/common/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +### Azure AD App Registration Setup + +#### 1. Create App Registration +1. Go to [Azure Portal](https://portal.azure.com/) +2. Navigate to "Azure Active Directory" > "App registrations" +3. Click "New registration" +4. Enter app name (e.g., "Structures UI") +5. Select supported account types: + - **Single tenant**: "Accounts in this organizational directory only" + - **Multi-tenant**: "Accounts in any organizational directory" +6. Set redirect URI: `http://localhost:5173/login` +7. Click "Register" + +#### 2. Configure Authentication +1. Go to "Authentication" in your app registration +2. Add platform: "Single-page application (SPA)" +3. Add redirect URIs: + - `http://localhost:5173/login` + - `http://localhost:5173/login/silent-renew` +4. Under "Advanced settings": + - Set "Allow public client flows" to "Yes" +5. Save changes + +#### 3. Get Client Information +1. Copy the "Application (client) ID" +2. Note the "Directory (tenant) ID" +3. Use these values in your environment variables + +### Testing the Configuration + +#### 1. Verify Environment Variables +```bash +# Check that all required variables are set +echo $VITE_OIDC_MICROSOFT_ENABLED +echo $VITE_MICROSOFT_CLIENT_ID +echo $VITE_MICROSOFT_AUTHORITY +echo $VITE_MICROSOFT_REDIRECT_URI +``` + +#### 2. Test Login Flow +1. Start your frontend application +2. Navigate to the login page +3. Click "Continue with Microsoft" +4. Complete the Microsoft login flow +5. Verify successful redirect to your application + +### Common Issues and Solutions + +#### Issue: "Application not found" +**Solution**: Verify the client ID is correct in your environment variables + +#### Issue: "Redirect URI mismatch" +**Solution**: Ensure the redirect URI in Azure AD matches exactly with your environment variable + +#### Issue: "Invalid client" +**Solution**: Check that the application is properly configured and the client ID is correct + +#### Issue: "AADSTS50011" +**Solution**: Verify the redirect URI in Azure AD matches your application's redirect URI + +### Debug Information + +#### Check Current Configuration +The application logs the OIDC configuration on startup: +```javascript +console.log('Creating UserManager with settings:', { + authority: settings.authority, + client_id: settings.client_id, + redirect_uri: settings.redirect_uri, + response_type: settings.response_type, + response_mode: settings.response_mode +}); +``` + +#### Browser Console Debugging +1. Open browser developer tools +2. Go to Console tab +3. Look for OIDC-related log messages +4. Check for any error messages during login + +### Security Considerations + +#### Single-Tenant vs Multi-Tenant +- **Single-Tenant**: More secure, only your organization's users can access +- **Multi-Tenant**: Allows users from other organizations to access (if configured) + +#### Redirect URI Security +- Always use HTTPS in production +- Ensure redirect URIs are exact matches +- Avoid wildcard redirect URIs + +### Production Deployment + +#### Environment Variables for Production +```bash +# .env.production +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-production-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_REDIRECT_URI=https://your-domain.com/login +VITE_MICROSOFT_POST_LOGOUT_REDIRECT_URI=https://your-domain.com +VITE_MICROSOFT_SILENT_REDIRECT_URI=https://your-domain.com/login/silent-renew +``` + +#### Azure AD Production Configuration +1. Update redirect URIs in Azure AD to use your production domain +2. Ensure HTTPS is used for all redirect URIs +3. Test the complete authentication flow in production \ No newline at end of file diff --git a/structures-auth/oidc-docs/entra/README.md b/structures-auth/oidc-docs/entra/README.md new file mode 100644 index 000000000..7f145ad35 --- /dev/null +++ b/structures-auth/oidc-docs/entra/README.md @@ -0,0 +1,192 @@ +# Microsoft Entra ID (Azure AD) OIDC Documentation + +This folder contains comprehensive documentation for configuring and troubleshooting Microsoft Entra ID (formerly Azure AD) OpenID Connect authentication. + +## Documentation Index + +### Configuration Guides + +#### [Microsoft Audience Configuration](./MICROSOFT_AUDIENCE_CONFIGURATION.md) +- **Purpose**: Complete guide for configuring Microsoft OIDC audience settings +- **Covers**: Frontend and backend configuration, JWT token structure, security best practices +- **Use Case**: Setting up Microsoft OIDC authentication with proper audience validation + +#### [Microsoft Client ID as Audience](./MICROSOFT_CLIENT_ID_AUDIENCE.md) +- **Purpose**: Guide for using your application's client ID as the JWT audience +- **Covers**: Custom audience configuration, Azure AD API setup, scope parameters +- **Use Case**: When you need application-specific tokens instead of Microsoft Graph API tokens + +### Troubleshooting Guides + +#### [Microsoft OIDC Troubleshooting](./MICROSOFT_OIDC_TROUBLESHOOTING.md) +- **Purpose**: General troubleshooting for Microsoft OIDC authentication +- **Covers**: Common errors, configuration issues, setup problems +- **Use Case**: When experiencing general Microsoft OIDC authentication issues + +#### [AADSTS901002 Error Troubleshooting](./MICROSOFT_AADSTS901002_TROUBLESHOOTING.md) +- **Purpose**: Specific troubleshooting for the "resource parameter not supported" error +- **Covers**: Azure AD v2.0 configuration, scope vs resource parameters +- **Use Case**: When getting "AADSTS901002: The 'resource' request parameter is not supported" + +## Quick Start + +### 1. Basic Configuration (Recommended) +For most applications, use the standard Microsoft Graph API audience: + +**Frontend Configuration:** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +``` + +**Backend Configuration:** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "00000003-0000-0000-c000-000000000000" # Microsoft Graph API +``` + +### 2. Custom Audience Configuration +If you need your application's client ID as the audience: + +**Frontend Configuration:** +```bash +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-application-client-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_RESOURCE=your-application-client-id +``` + +**Backend Configuration:** +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "your-application-client-id" +``` + +## Common Issues + +### AADSTS901002 Error +- **Error**: "The 'resource' request parameter is not supported" +- **Solution**: Use scope parameter instead of resource parameter +- **Guide**: [AADSTS901002 Troubleshooting](./MICROSOFT_AADSTS901002_TROUBLESHOOTING.md) + +### AADSTS650053 Error +- **Error**: "The application asked for scope that doesn't exist" +- **Solution**: Use Microsoft Graph API audience instead of custom scope +- **Guide**: [Audience Configuration](./MICROSOFT_AUDIENCE_CONFIGURATION.md) + +### JWT Signature Validation +- **Error**: "JWT signature does not match locally computed signature" +- **Solution**: Verify issuer and audience configuration +- **Guide**: [Audience Configuration](./MICROSOFT_AUDIENCE_CONFIGURATION.md) + +## Key Concepts + +### Microsoft Entra ID Architecture +- **Authentication Endpoint**: `https://login.microsoftonline.com` (where users log in) +- **Token Issuer**: `https://sts.windows.net` (where tokens are issued) +- **JWKS Endpoint**: `https://login.microsoftonline.com/common/discovery/v2.0/keys` + +### JWT Token Structure +```json +{ + "aud": "00000003-0000-0000-c000-000000000000", // Microsoft Graph API + "iss": "https://sts.windows.net", // Token issuer + "sub": "user-id", // User identifier + "exp": 1640995200, // Expiration time + "kid": "key-id-from-jwks" // Key identifier +} +``` + +### Audience Options +1. **Microsoft Graph API** (Recommended): `00000003-0000-0000-c000-000000000000` +2. **Custom Application**: Your application's client ID (requires API configuration) + +## Security Best Practices + +### 1. Audience Validation +- Always validate the audience claim +- Use exact string matching (case-sensitive) +- Log authentication events for monitoring + +### 2. Issuer Validation +- Strictly validate issuer claims +- Use `https://sts.windows.net` for Microsoft tokens +- Never skip issuer validation + +### 3. Token Security +- Validate token expiration +- Check token signature +- Verify issuer claims +- Monitor for suspicious activity + +## Azure Portal Setup + +### 1. Application Registration +1. Go to [Azure Portal](https://portal.azure.com/) +2. Navigate to "Microsoft Entra ID" > "App registrations" +3. Create new registration or select existing +4. Configure authentication settings + +### 2. Authentication Configuration +1. Go to "Authentication" in your app registration +2. Add platform: "Single-page application (SPA)" +3. Add redirect URIs: + - `http://localhost:5173/login` (development) + - `https://your-domain.com/login` (production) +4. Set "Allow public client flows" to "Yes" + +### 3. API Permissions (Optional) +1. Go to "API permissions" +2. Add Microsoft Graph permissions if needed +3. Grant admin consent if required + +## Testing + +### 1. Verify Configuration +```javascript +// Check JWT token in browser console +const token = 'your-jwt-token'; +const payload = JSON.parse(atob(token.split('.')[1])); +console.log('Audience:', payload.aud); +console.log('Issuer:', payload.iss); +``` + +### 2. Test JWKS Endpoint +```bash +# Test Microsoft's JWKS endpoint +curl -s "https://login.microsoftonline.com/common/discovery/v2.0/keys" | jq +``` + +### 3. Verify Backend Logs +```yaml +logging: + level: + org.kinotic.structures.internal.config: DEBUG + org.kinotic.structures.internal.security: DEBUG +``` + +## Related Documentation + +- [OIDC Implementation Guide](../structures-core/OIDC_IMPLEMENTATION.md) - Core OIDC implementation details +- Basic Auth Configuration - See `structures-frontend-next/CONFIGURATION.md` (section "Basic Authentication") +- [Frontend OIDC Configuration](../structures-frontend-next/src/pages/login/OidcConfiguration.ts) - Frontend OIDC setup + +## Support + +For issues not covered in these guides: +1. Check the troubleshooting guides above +2. Review application logs with debug logging enabled +3. Verify Azure Portal configuration +4. Test with Microsoft's OIDC endpoints directly \ No newline at end of file diff --git a/structures-auth/oidc-docs/okta.md b/structures-auth/oidc-docs/okta.md new file mode 100644 index 000000000..1cb2b48f7 --- /dev/null +++ b/structures-auth/oidc-docs/okta.md @@ -0,0 +1,406 @@ +# Okta OIDC Configuration Guide + +## Overview + +This guide explains how to configure Okta OIDC authentication for the Structures project. **Important**: Okta access tokens MUST include an email claim for authentication to succeed. + +## Critical Requirements + +### Access Token Email Claim Requirement + +**⚠️ CRITICAL**: Your Okta OIDC application MUST be configured to include the `email` claim in the access token. The Structures authentication system requires this claim to function properly. + +**Authentication Flow:** +1. **Primary**: Look for `email` claim in access token +2. **Fallback 1**: If no email claim, try `preferred_username` claim (must be a valid email format) +3. **Fallback 2**: If no preferred_username, try `sub` claim (must be a valid email format) +4. **Failure**: If none exist or none are valid emails, authentication will fail + +**Why This Matters:** +- The system uses email for user identification and tenant routing +- Without email information, user sessions cannot be created +- This is a security requirement, not optional + +**Supported Claims (in priority order):** +- **`email`**: Standard OIDC email claim (recommended) +- **`preferred_username`**: Standard OIDC preferred username claim (often email format) +- **`sub`**: Standard OIDC subject claim (if it's an email format) +- **`upn`**: Microsoft-specific User Principal Name claim +- **`unique_name`**: Microsoft-specific legacy claim + +## Okta Application Configuration + +### Step 1: Create OIDC Application in Okta + +1. **Log into Okta Admin Console** +2. **Navigate to Applications > Applications** +3. **Click "Create App Integration"** +4. **Select "OIDC - OpenID Connect"** +5. **Choose "Single-page application (SPA)"** + +### Step 2: Configure Application Settings + +#### Basic Information +- **App name**: `Structures Application` (or your preferred name) +- **Logo**: Optional +- **App type**: Single-page application (SPA) + +#### Sign-in Method +- **Grant type**: Authorization Code +- **PKCE**: **REQUIRED** - Check "Proof Key for Code Exchange (PKCE)" +- **Redirect URIs**: + - Development: `http://localhost:5173/login` + - Production: `https://your-domain.com/login` +- **Sign-out redirect URIs**: + - Development: `http://localhost:5173` + - Production: `https://your-domain.com` + +### Step 3: Configure Token Claims (CRITICAL) + +#### Access Token Claims +You MUST configure the following claims to be included in the access token: + +1. **Go to "Sign On" tab** +2. **Click "Edit" in the "OpenID Connect ID Token" section** +3. **Under "Claims" add:** + - `email` - **REQUIRED** - Include in access token + - `name` - Optional but recommended + - `preferred_username` - Optional but recommended + - `groups` - Optional, for role-based access control + +#### Claim Configuration Example +``` +Claim Name: email +Claim Type: Expression +Value: user.email +Include in: Access Token (REQUIRED) + +Claim Name: preferred_username +Claim Type: Expression +Value: user.preferredUsername +Include in: Access Token (RECOMMENDED) + +Claim Name: groups +Claim Type: Expression +Value: appuser.groups +Include in: Access Token (OPTIONAL) +``` + +### Step 4: Configure Groups Scope and Claims (Optional) + +#### Create Groups Scope +1. **Go to "Sign On" tab** +2. **Scroll down to "Scopes" section** +3. **Click "Add Scope"** +4. **Configure the scope:** + - **Name**: `groups` + - **Display Name**: `Groups` + - **Description**: `Access to user group information` + - **Consent**: Check "Consent required" if you want user approval + +#### Configure Groups Claim with Regex Matching +1. **Go to "Sign On" tab** +2. **Click "Edit" in the "OpenID Connect ID Token" section** +3. **Under "Claims" add:** + - **Claim Name**: `groups` + - **Claim Type**: Expression + - **Value**: `appuser.groups` + - **Include in**: Access Token + - **Set value type**: Array + - **Group filter**: Check "Filter" and use regex pattern `.*` + +#### Selective Groups Scope Usage +This configuration allows you to: +- **Request groups only when needed**: Use `scope: 'openid profile email groups'` for specific requests +- **Control group access**: Users can consent to group information sharing +- **Flexible implementation**: Different applications can request different levels of group access + +**Example Scopes:** +``` +Basic authentication: 'openid profile email' +With groups: 'openid profile email groups' +With custom claims: 'openid profile email groups custom_scope' +``` + +### Step 4: Assign Users and Groups + +1. **Go to "Assignments" tab** +2. **Assign users or groups to the application** +3. **Ensure users have email addresses configured** + +## Frontend Configuration + +### Environment Variables +```bash +# .env.development +VITE_OIDC_OKTA_ENABLED=true +VITE_OKTA_CLIENT_ID=your-okta-client-id +VITE_OKTA_AUTHORITY=https://your-okta-domain.okta.com/oauth2/default +VITE_OKTA_REDIRECT_URI=http://localhost:5173/login +VITE_OKTA_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_OKTA_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +### OIDC Configuration +```typescript +// In OidcConfiguration.ts +okta: { + client_id: 'your-okta-client-id', + client_secret: '', // Not needed for SPA + authority: 'https://your-okta-domain.okta.com/oauth2/default', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + loadUserInfo: true, + scope: 'openid profile email', // email scope is REQUIRED for email claim + // Optional: Add 'groups' scope if you need group/role information + // scope: 'openid profile email groups', + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + metadata: { + authorization_endpoint: 'https://your-okta-domain.okta.com/oauth2/default/v1/authorize', + token_endpoint: 'https://your-okta-domain.okta.com/oauth2/default/v1/token', + userinfo_endpoint: 'https://your-okta-domain.okta.com/oauth2/default/v1/userinfo', + end_session_endpoint: 'https://your-okta-domain.okta.com/oauth2/default/v1/logout', + jwks_uri: 'https://your-okta-domain.okta.com/oauth2/default/v1/keys' + } +} +``` + +### Dynamic Scope Configuration + +For applications that need flexible group access, you can implement dynamic scope selection: + +```typescript +// Function to get scope based on requirements +function getScope(includeGroups: boolean = false): string { + const baseScope = 'openid profile email'; + return includeGroups ? `${baseScope} groups` : baseScope; +} + +// Usage examples +const basicScope = getScope(); // 'openid profile email' +const groupsScope = getScope(true); // 'openid profile email groups' + +// Apply to OIDC configuration +const userManagerSettings = { + ...baseSettings, + scope: getScope(needsGroupAccess) +}; +``` + +## Backend Configuration + +### Application Properties +```yaml +# application.yml or application-{profile}.yml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://your-okta-domain.okta.com/oauth2/default" + authorization-audiences: + - "your-okta-client-id" +``` + +### OIDC Provider Configuration +```yaml +oidc-security-service: + oidc-providers: + - provider: "okta" + authority: "https://your-okta-domain.okta.com/oauth2/default" + audience: "your-okta-client-id" + domains: ["your-domain.com"] # Optional: restrict to specific email domains + roles-claim-path: "groups" # Optional: extract roles from groups claim + enabled: true +``` + +## Testing Your Configuration + +### Step 1: Verify Access Token Claims +1. **Login with Okta** +2. **Check browser developer tools > Application > Local Storage** +3. **Look for the access token** +4. **Decode the JWT at [jwt.io](https://jwt.io)** +5. **Verify at least one of these claims is present:** + - `email` (recommended) + - `preferred_username` (if it's an email format) + - `sub` (if it's an email format) + +### Step 2: Check Authentication Flow +1. **Login should succeed** +2. **User should be redirected to the application** +3. **Check backend logs for successful authentication** + +### Step 3: Verify User Information +1. **Check that user email is correctly extracted** +2. **Verify tenant routing works (if using multi-tenancy)** +3. **Confirm roles are extracted (if configured)** + +### Step 4: Test Groups Scope (Optional) +1. **Test basic authentication** with scope `openid profile email` +2. **Test groups access** with scope `openid profile email groups` +3. **Verify groups claim** in access token using JWT decoder +4. **Check backend logs** for role extraction +5. **Test selective scope usage** by requesting different scopes for different operations + +## Troubleshooting + +### Common Issues + +#### Issue: "No email found in claims" Error +**Cause**: Access token doesn't contain any of the supported email claims +**Solution**: +1. Check Okta application configuration +2. Ensure at least one of these claims is included in access token: + - `email` (recommended) + - `preferred_username` (if it's an email format) + - `sub` (if it's an email format) +3. Verify claim mapping is correct +4. Check that the claim value is a valid email format + +#### Issue: "Invalid issuer" Error +**Cause**: Issuer URL doesn't match configuration +**Solution**: +1. Check `authority` in frontend configuration +2. Verify `allowed-issuers` in backend configuration +3. Ensure URLs match exactly (including `/oauth2/default`) + +#### Issue: "Invalid audience" Error +**Cause**: Client ID doesn't match audience configuration +**Solution**: +1. Check `client_id` in frontend configuration +2. Verify `authorization-audiences` in backend configuration +3. Ensure client ID matches exactly + +#### Issue: Authentication Fails Silently +**Cause**: Missing or invalid email claim +**Solution**: +1. Check access token claims using JWT decoder +2. Verify email claim is present and valid +3. Check backend logs for specific error messages + +#### Issue: Groups Not Appearing in Access Token +**Cause**: Groups scope or claim not properly configured +**Solution**: +1. Verify groups scope is created in Okta application +2. Check that groups claim is configured with regex filter `.*` +3. Ensure scope includes `groups` when requesting authentication +4. Verify user is assigned to groups in Okta +5. Check claim mapping uses `appuser.groups` value + +#### Issue: Groups Claim is Empty Array +**Cause**: User not assigned to groups or filter too restrictive +**Solution**: +1. Assign user to groups in Okta Admin Console +2. Check group filter regex pattern (use `.*` for all groups) +3. Verify groups are active and enabled +4. Test with different users who have group assignments + +### Debug Mode + +Enable debug logging to troubleshoot issues: + +```yaml +# Backend configuration +oidc-security-service: + debug: true + +# Frontend configuration +debug: true +``` + +## Security Considerations + +### Required Scopes +- `openid`: Required for OIDC compliance +- `profile`: Recommended for user information +- `email`: **REQUIRED** for email claim + +### Token Validation +- JWT signature verification using JWKS +- Issuer validation against allowed issuers +- Audience validation against configured audiences +- Expiration validation +- Email claim validation + +### Best Practices +1. **Use HTTPS in production** +2. **Implement proper CORS configuration** +3. **Regular security audits of Okta configuration** +4. **Monitor authentication logs** +5. **Use least privilege principle for user assignments** + +## Example Complete Configuration + +### Okta Application Settings +``` +App Name: Structures Application +App Type: Single-page application (SPA) +Grant Type: Authorization Code +PKCE: Enabled +Redirect URIs: http://localhost:5173/login, https://your-domain.com/login +Sign-out URIs: http://localhost:5173, https://your-domain.com +``` + +### Access Token Claims +``` +email -> user.email (REQUIRED - recommended) +preferred_username -> user.preferredUsername (RECOMMENDED - fallback) +name -> user.displayName (OPTIONAL) +groups -> appuser.groups (OPTIONAL - for roles) +``` + +### Frontend Configuration +```typescript +okta: { + enabled: true, + client_id: '0oa1a2b3c4d5e6f7g8h9', + authority: 'https://your-okta-domain.okta.com/oauth2/default', + redirect_uri: 'http://localhost:5173/login', + post_logout_redirect_uri: 'http://localhost:5173', + silent_redirect_uri: 'http://localhost:5173/login/silent-renew', + scope: 'openid profile email' // Add 'groups' when role information is needed +} + +// For applications requiring group access: +oktaWithGroups: { + ...okta, + scope: 'openid profile email groups' +} +``` + +### Backend Configuration +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://your-okta-domain.okta.com/oauth2/default" + authorization-audiences: + - "0oa1a2b3c4d5e6f7g8h9" + +oidc-security-service: + oidc-providers: + - provider: "okta" + authority: "https://your-okta-domain.okta.com/oauth2/default" + audience: "0oa1a2b3c4d5e6f7g8h9" + domains: ["your-domain.com"] + roles-claim-path: "groups" + enabled: true +``` + +## Support + +If you encounter issues with Okta OIDC configuration: + +1. **Check this documentation first** +2. **Verify access token claims using JWT decoder** +3. **Enable debug logging** +4. **Check Okta application configuration** +5. **Review backend authentication logs** + +**Remember**: The email claim in the access token is **REQUIRED** for authentication to succeed. diff --git a/structures-auth/oidc-docs/social/README.md b/structures-auth/oidc-docs/social/README.md new file mode 100644 index 000000000..ba33bc5e0 --- /dev/null +++ b/structures-auth/oidc-docs/social/README.md @@ -0,0 +1,185 @@ +# Social Login Documentation + +This folder contains documentation for configuring social login providers (Google, Microsoft, GitHub, etc.) for consumer-facing applications. + +## Social Login vs Enterprise OIDC + +### Social Login (Consumer) +- **Purpose**: Allow users to sign in with their personal social accounts +- **Audience**: General public, consumer applications +- **Configuration**: Uses social provider's OAuth2/OIDC endpoints +- **User Management**: Users manage their own accounts +- **Examples**: Google, Microsoft Personal, GitHub, Facebook + +### Enterprise OIDC (Business) +- **Purpose**: Enterprise authentication with organizational control +- **Audience**: Employees, business applications +- **Configuration**: Uses organization's identity provider (Entra ID, Okta, etc.) +- **User Management**: IT department manages user accounts +- **Examples**: Microsoft Entra ID, Okta, Keycloak + +## Documentation Index + +### [Microsoft Social Login](./microsoft-social.md) +- **Purpose**: Configure Microsoft personal account login +- **Covers**: Personal Microsoft account OAuth2 setup, consumer audience +- **Use Case**: When users sign in with personal Microsoft accounts + +### [Google Social Login](./google-social.md) +- **Purpose**: Configure Google OAuth2 login +- **Covers**: Google Cloud Console setup, OAuth2 configuration +- **Use Case**: When users sign in with Google accounts + +### [GitHub Social Login](./github-social.md) +- **Purpose**: Configure GitHub OAuth login +- **Covers**: GitHub OAuth app setup, personal account authentication +- **Use Case**: When users sign in with GitHub accounts + +## Key Differences + +### Configuration Differences + +| Aspect | Social Login | Enterprise OIDC | +|--------|-------------|-----------------| +| **Provider Setup** | Social platform (Google Console, GitHub Apps) | Identity provider (Entra ID, Okta) | +| **Audience** | Consumer applications | Business applications | +| **User Accounts** | Personal accounts | Organizational accounts | +| **Authentication** | OAuth2/OIDC with personal providers | OIDC with enterprise provider | +| **User Management** | Self-service | IT managed | +| **Scopes** | Basic profile access | Enterprise permissions | + +### Technical Differences + +#### Social Login (Microsoft Personal) +```yaml +# Frontend Configuration +VITE_MICROSOFT_SOCIAL_CLIENT_ID=your-consumer-app-id +VITE_MICROSOFT_SOCIAL_AUTHORITY=https://login.microsoftonline.com/consumers/v2.0 + +# Backend Configuration +structures: + oidc-auth-verifier: + allowed-issuers: + - "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" + authorization-audiences: + - "your-consumer-app-id" +``` + +#### Enterprise OIDC (Microsoft Entra ID) +```yaml +# Frontend Configuration +VITE_MICROSOFT_CLIENT_ID=your-enterprise-app-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 + +# Backend Configuration +structures: + oidc-auth-verifier: + allowed-issuers: + - "https://sts.windows.net" + authorization-audiences: + - "00000003-0000-0000-c000-000000000000" +``` + +## Common Social Login Providers + +### Microsoft Personal Accounts +- **Endpoint**: `https://login.microsoftonline.com/consumers/v2.0` +- **Issuer**: `https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0` +- **Audience**: Your consumer app client ID +- **Use Case**: Personal Microsoft account login + +### Google Accounts +- **Endpoint**: `https://accounts.google.com` +- **Issuer**: `https://accounts.google.com` +- **Audience**: Your Google OAuth client ID +- **Use Case**: Personal Google account login + +### GitHub Accounts +- **Endpoint**: `https://github.com` +- **Issuer**: `https://github.com` +- **Audience**: Your GitHub OAuth app client ID +- **Use Case**: Personal GitHub account login + +## Implementation Guide + +### Step 1: Choose Your Approach +1. **Social Login**: For consumer applications, personal accounts +2. **Enterprise OIDC**: For business applications, organizational accounts + +### Step 2: Configure Provider +1. **Social**: Set up OAuth2 app in social platform +2. **Enterprise**: Configure app registration in identity provider + +### Step 3: Update Application +1. **Frontend**: Configure OIDC client with correct endpoints +2. **Backend**: Update issuer and audience validation + +### Step 4: Test Authentication +1. **Social**: Test with personal accounts +2. **Enterprise**: Test with organizational accounts + +## Security Considerations + +### Social Login Security +- **User Consent**: Users must consent to app permissions +- **Limited Scopes**: Only basic profile information +- **Personal Data**: Handle personal data according to privacy laws +- **Account Linking**: Consider how to link social accounts to your app + +### Enterprise OIDC Security +- **Organizational Control**: IT manages user accounts +- **Advanced Permissions**: Can request enterprise scopes +- **Compliance**: Must meet organizational security requirements +- **Single Sign-On**: Integrates with enterprise SSO + +## Example Configurations + +### Social Login Setup +```bash +# Environment Variables +VITE_OIDC_MICROSOFT_SOCIAL_ENABLED=true +VITE_MICROSOFT_SOCIAL_CLIENT_ID=your-consumer-app-id +VITE_MICROSOFT_SOCIAL_AUTHORITY=https://login.microsoftonline.com/consumers/v2.0 +VITE_MICROSOFT_SOCIAL_REDIRECT_URI=http://localhost:5173/login +``` + +### Enterprise OIDC Setup +```bash +# Environment Variables +VITE_OIDC_MICROSOFT_ENABLED=true +VITE_MICROSOFT_CLIENT_ID=your-enterprise-app-id +VITE_MICROSOFT_AUTHORITY=https://login.microsoftonline.com/your-tenant-id/v2.0 +VITE_MICROSOFT_REDIRECT_URI=http://localhost:5173/login +``` + +## Testing + +### Social Login Testing +1. **Use Personal Accounts**: Test with personal Microsoft/Google/GitHub accounts +2. **Check Permissions**: Verify only requested scopes are granted +3. **Test Account Linking**: Ensure social accounts link to your app users + +### Enterprise OIDC Testing +1. **Use Organizational Accounts**: Test with enterprise accounts +2. **Check Enterprise Permissions**: Verify enterprise scopes work correctly +3. **Test SSO Integration**: Ensure single sign-on works properly + +## Related Documentation + +- [Microsoft Entra ID (Enterprise)](../entra/) - Enterprise Microsoft authentication +- [Core OIDC Implementation](../../structures-core/OIDC_IMPLEMENTATION.md) - Underlying OIDC implementation +- [Frontend OIDC Configuration](../../structures-frontend-next/src/pages/login/OidcConfiguration.ts) - Frontend configuration + +## Support + +For social login issues: +1. Check provider-specific documentation in this folder +2. Verify OAuth2 app configuration in social platform +3. Test with personal accounts +4. Review OAuth2 scopes and permissions + +For enterprise OIDC issues: +1. Check [Enterprise Documentation](../entra/) +2. Verify app registration in identity provider +3. Test with organizational accounts +4. Review enterprise permissions and policies \ No newline at end of file diff --git a/structures-auth/oidc-docs/social/microsoft-social.md b/structures-auth/oidc-docs/social/microsoft-social.md new file mode 100644 index 000000000..dbdf0698b --- /dev/null +++ b/structures-auth/oidc-docs/social/microsoft-social.md @@ -0,0 +1,284 @@ +# Microsoft Social Login (Personal Accounts) + +## Overview + +This guide explains how to configure Microsoft social login for personal Microsoft accounts (Outlook.com, Hotmail, etc.) using the Microsoft Identity Platform for consumers. + +**Key Difference**: This is for personal Microsoft accounts, not enterprise Entra ID accounts. + +## Microsoft Identity Platform for Consumers + +### Consumer Endpoints +- **Authority**: `https://login.microsoftonline.com/consumers/v2.0` +- **Issuer**: `https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0` +- **Audience**: Your consumer app client ID +- **Scopes**: `openid profile email` + +### Consumer Tenant ID +Microsoft uses a special tenant ID for consumer accounts: +- **Consumer Tenant ID**: `9188040d-6c67-4c5b-b112-36a304b66dad` +- **Purpose**: Identifies Microsoft consumer accounts (Outlook.com, Hotmail, etc.) + +## Setup Process + +### Step 1: Register Your Application + +#### 1. Go to Azure Portal +1. Navigate to [Azure Portal](https://portal.azure.com/) +2. Go to "Microsoft Entra ID" > "App registrations" +3. Click "New registration" + +#### 2. Configure Application +1. **Name**: Enter your application name (e.g., "My App - Social Login") +2. **Supported account types**: Select "Personal Microsoft accounts only" +3. **Redirect URI**: + - Platform: "Single-page application (SPA)" + - URI: `http://localhost:5173/login` (development) + - URI: `https://your-domain.com/login` (production) +4. Click "Register" + +#### 3. Get Application Information +1. **Application (client) ID**: Copy this for your configuration +2. **Directory (tenant) ID**: This will be the consumer tenant ID +3. **Object ID**: Note this for reference + +### Step 2: Configure Authentication + +#### 1. Authentication Settings +1. Go to "Authentication" in your app registration +2. Add platform: "Single-page application (SPA)" +3. Add redirect URIs: + - `http://localhost:5173/login` (development) + - `https://your-domain.com/login` (production) +4. Under "Advanced settings": + - Set "Allow public client flows" to "Yes" +5. Save changes + +#### 2. API Permissions (Optional) +1. Go to "API permissions" +2. Click "Add a permission" +3. Select "Microsoft Graph" +4. Choose "Delegated permissions" +5. Select scopes: + - `openid` (always required) + - `profile` (for user profile) + - `email` (for email address) +6. Click "Add permissions" + +### Step 3: Frontend Configuration + +#### Environment Variables +```bash +# .env.development +VITE_OIDC_MICROSOFT_SOCIAL_ENABLED=true +VITE_MICROSOFT_SOCIAL_CLIENT_ID=your-consumer-app-client-id +VITE_MICROSOFT_SOCIAL_AUTHORITY=https://login.microsoftonline.com/consumers/v2.0 +VITE_MICROSOFT_SOCIAL_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_SOCIAL_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SOCIAL_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +#### OIDC Configuration +```typescript +// Add to your OIDC configuration +microsoftSocial: { + enabled: env.VITE_OIDC_MICROSOFT_SOCIAL_ENABLED === 'true', + client_id: env.VITE_MICROSOFT_SOCIAL_CLIENT_ID || '', + client_secret: '', + authority: env.VITE_MICROSOFT_SOCIAL_AUTHORITY || 'https://login.microsoftonline.com/consumers/v2.0', + redirect_uri: env.VITE_MICROSOFT_SOCIAL_REDIRECT_URI || '', + post_logout_redirect_uri: env.VITE_MICROSOFT_SOCIAL_POST_LOGOUT_REDIRECT_URI || '', + silent_redirect_uri: env.VITE_MICROSOFT_SOCIAL_SILENT_REDIRECT_URI || '', + loadUserInfo: true, + scope: 'openid profile email', + metadata: { + authorization_endpoint: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize', + token_endpoint: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token', + userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo', + end_session_endpoint: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/logout', + jwks_uri: 'https://login.microsoftonline.com/consumers/discovery/v2.0/keys', + }, +} +``` + +### Step 4: Backend Configuration + +#### Application Properties +```yaml +# application-dev.yml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" + authorization-audiences: + - "your-consumer-app-client-id" +``` + +## JWT Token Structure + +### Expected JWT Token +```json +{ + "aud": "your-consumer-app-client-id", + "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", + "sub": "user-id", + "exp": 1640995200, + "scp": "openid profile email", + "tid": "9188040d-6c67-4c5b-b112-36a304b66dad" +} +``` + +### Key Differences from Enterprise +- **Issuer**: Uses consumer tenant ID instead of `sts.windows.net` +- **Audience**: Your app's client ID instead of Microsoft Graph API +- **Tenant ID**: Always `9188040d-6c67-4c5b-b112-36a304b66dad` + +## Testing + +### Step 1: Verify Configuration +```javascript +// Check JWT token in browser console +const token = 'your-jwt-token'; +const payload = JSON.parse(atob(token.split('.')[1])); +console.log('Audience:', payload.aud); +console.log('Issuer:', payload.iss); +console.log('Tenant ID:', payload.tid); +``` + +### Step 2: Test Login Flow +1. **Start your application** +2. **Navigate to login page** +3. **Click "Continue with Microsoft"** +4. **Sign in with personal Microsoft account** (Outlook.com, Hotmail, etc.) +5. **Verify successful redirect** + +### Step 3: Verify Token Validation +1. **Check backend logs** for successful JWT validation +2. **Verify user information** is extracted correctly +3. **Test logout flow** + +## Common Issues + +### Issue 1: "Invalid issuer" Error +**Cause**: Backend configured for enterprise issuer +**Solution**: Update `allowed-issuers` to include consumer issuer: +```yaml +allowed-issuers: + - "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" +``` + +### Issue 2: "Invalid audience" Error +**Cause**: Backend expecting Microsoft Graph API audience +**Solution**: Update `authorization-audiences` to include your client ID: +```yaml +authorization-audiences: + - "your-consumer-app-client-id" +``` + +### Issue 3: "Account type not supported" Error +**Cause**: App registered for organizational accounts only +**Solution**: Update app registration to support personal accounts: +1. Go to Azure Portal > App registrations > Your app +2. Go to "Authentication" +3. Change "Supported account types" to "Personal Microsoft accounts only" + +### Issue 4: "Redirect URI mismatch" Error +**Cause**: Redirect URI not configured correctly +**Solution**: +1. Verify redirect URI in Azure Portal matches your configuration +2. Check for exact string matching (including protocol and port) + +## Security Considerations + +### 1. Consumer Account Security +- **Personal Data**: Handle personal Microsoft account data carefully +- **Privacy Compliance**: Follow privacy laws (GDPR, CCPA, etc.) +- **User Consent**: Ensure users understand what data you're accessing +- **Data Minimization**: Only request necessary scopes + +### 2. Token Security +- **Validate Issuer**: Always verify the consumer issuer +- **Validate Audience**: Ensure tokens are for your application +- **Check Expiration**: Validate token expiration +- **Monitor Usage**: Log authentication events + +### 3. Account Linking +- **User Experience**: Consider how to link social accounts to your app users +- **Data Consistency**: Ensure consistent user experience across login methods +- **Account Recovery**: Plan for account recovery scenarios + +## Example Complete Configuration + +### Frontend (.env.development) +```bash +VITE_OIDC_MICROSOFT_SOCIAL_ENABLED=true +VITE_MICROSOFT_SOCIAL_CLIENT_ID=c81b2096-7781-4eb1-a2f6-42371330add6 +VITE_MICROSOFT_SOCIAL_AUTHORITY=https://login.microsoftonline.com/consumers/v2.0 +VITE_MICROSOFT_SOCIAL_REDIRECT_URI=http://localhost:5173/login +VITE_MICROSOFT_SOCIAL_POST_LOGOUT_REDIRECT_URI=http://localhost:5173 +VITE_MICROSOFT_SOCIAL_SILENT_REDIRECT_URI=http://localhost:5173/login/silent-renew +``` + +### Backend (application-dev.yml) +```yaml +structures: + oidc-auth-verifier: + enabled: true + allowed-issuers: + - "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" + authorization-audiences: + - "c81b2096-7781-4eb1-a2f6-42371330add6" + +logging: + level: + org.kinotic.structures.internal.config: DEBUG + org.kinotic.structures.internal.security: DEBUG +``` + +### Azure Portal App Registration +- **Application (client) ID**: `c81b2096-7781-4eb1-a2f6-42371330add6` +- **Supported account types**: "Personal Microsoft accounts only" +- **Redirect URIs**: `http://localhost:5173/login` +- **Platform**: Single-page application (SPA) + +## Comparison: Social vs Enterprise + +| Aspect | Social Login | Enterprise OIDC | +|--------|-------------|-----------------| +| **Authority** | `consumers/v2.0` | `your-tenant-id/v2.0` | +| **Issuer** | Consumer tenant ID | `https://sts.windows.net` | +| **Audience** | Your client ID | Microsoft Graph API | +| **Accounts** | Personal Microsoft | Organizational | +| **User Management** | Self-service | IT managed | +| **Scopes** | Basic profile | Enterprise permissions | + +## Troubleshooting + +### Debug Logging +```yaml +logging: + level: + org.kinotic.structures.internal.config: DEBUG + org.kinotic.structures.internal.security: DEBUG +``` + +### Test JWKS Endpoint +```bash +# Test Microsoft consumer JWKS endpoint +curl -s "https://login.microsoftonline.com/consumers/discovery/v2.0/keys" | jq +``` + +### Verify Token Structure +```javascript +// Decode JWT in browser console +const token = 'your-jwt-token'; +const payload = JSON.parse(atob(token.split('.')[1])); +console.log('Token Structure:', payload); +``` + +## Related Documentation + +- [Microsoft Entra ID (Enterprise)](../entra/) - Enterprise Microsoft authentication +- [Social Login Overview](./README.md) - General social login information +- [Core OIDC Implementation](../../structures-core/OIDC_IMPLEMENTATION.md) - Underlying OIDC implementation \ No newline at end of file diff --git a/structures-auth/settings.gradle b/structures-auth/settings.gradle new file mode 100644 index 000000000..07e92f059 --- /dev/null +++ b/structures-auth/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'structures-auth' \ No newline at end of file diff --git a/structures-auth/src/main/java/org/kinotic/structures/auth/api/config/OidcSecurityServiceProperties.java b/structures-auth/src/main/java/org/kinotic/structures/auth/api/config/OidcSecurityServiceProperties.java new file mode 100644 index 000000000..72966155f --- /dev/null +++ b/structures-auth/src/main/java/org/kinotic/structures/auth/api/config/OidcSecurityServiceProperties.java @@ -0,0 +1,81 @@ +/* + * + * Copyright 2008-2021 Kinotic and the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kinotic.structures.auth.api.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import org.kinotic.structures.auth.api.domain.OidcProvider; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** + * Configuration class for OIDC authentication. + * + * The OIDC verifier will: + * 1. Extract the JWT token from the Authorization header + * 2. Verify the token signature using JWKS from the issuer + * 3. Validate the issuer against the list of providers + * 4. Validate the audience against the allowed audiences list of the provider + * 5. Check token expiration + * 6. Create a Participant with user information from the token claims + * + * Caching: + * - JWKS keys are cached for 1 hour + * - Well-known configurations are cached for 24 hours + * - Cache sizes are limited to prevent memory issues + */ +@Getter +@Setter +@Accessors(chain = true) +@NoArgsConstructor +@Component +@ConfigurationProperties(prefix = "oidc-security-service") +public class OidcSecurityServiceProperties { + + /** + * Master switch for enabling/disabling the OIDC security service. + */ + private boolean enabled = false; + + /** + * The field name for the tenant ID in the JWT token. + */ + private String tenantIdFieldName = "tenantId"; + + /** + * List of OIDC providers to be used for authentication. + */ + private List oidcProviders; + + /** + * enable debugging for the OIDC security service in the UI. + */ + private boolean debug; + + /** + * The path that the frontend configuration overrides will be served from. + * Will override any default configurations in the app-config.json file. + */ + private String frontendConfigurationPath = "/app-config.override.json"; + +} diff --git a/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java b/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java new file mode 100644 index 000000000..43388f218 --- /dev/null +++ b/structures-auth/src/main/java/org/kinotic/structures/auth/api/domain/OidcProvider.java @@ -0,0 +1,95 @@ +package org.kinotic.structures.auth.api.domain; + +import java.util.List; +import java.util.Map; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; + +@Getter +@Setter +@Accessors(chain = true) +@NoArgsConstructor +public class OidcProvider { + + /** + * Master switch for enabling/disabling the OIDC provider. + */ + private boolean enabled; + + /** + * The name of the OIDC provider. + */ + private String provider; + + /** + * The display name of the OIDC provider. + */ + private String displayName; + + /** + * The client ID of the OIDC provider. + */ + private String clientId; + + /** + * The authority of the OIDC provider. This should be fully qualified, + * i.e. + * https://your-authority.com/auth/realms/your-realm + * or + * https://something.okta.com/oauth2/default + */ + private String authority; + + /** + * The redirect URI of the OIDC provider. + */ + private String redirectUri; + + /** + * The post logout redirect URI of the OIDC provider. + */ + private String postLogoutRedirectUri; + + /** + * The silent redirect URI of the OIDC provider. + */ + private String silentRedirectUri; + + /** + * The domains of the OIDC provider. + */ + private List domains; + + /** + * The audience this service will expect for this OIDC provider. + */ + private String audience; + + /** + * The roles of the OIDC provider. + */ + private List roles; + + /** + * Any additional metadata of the OIDC provider, will be added to the Participant metadata. + */ + private Map metadata; + /** + * Json path to the roles claim which must be a list of strings. + * If not provided, we do not extract roles from the JWT token. + * If nested, the path is a dot-separated string of the nested properties. + * Example: "realm_access.roles" + */ + private String rolesClaimPath; + + /** + * Any additional scopes to be added to the OIDC provider. + * If not provided, we do not add any additional scopes. + * Example: "groups" + */ + private String additionalScopes; + +} diff --git a/structures-auth/src/main/java/org/kinotic/structures/auth/api/services/JwksService.java b/structures-auth/src/main/java/org/kinotic/structures/auth/api/services/JwksService.java new file mode 100644 index 000000000..0feb96114 --- /dev/null +++ b/structures-auth/src/main/java/org/kinotic/structures/auth/api/services/JwksService.java @@ -0,0 +1,20 @@ +package org.kinotic.structures.auth.api.services; + +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.databind.JsonNode; +import io.jsonwebtoken.security.Jwk; +import java.security.Key; + +public interface JwksService { + + CompletableFuture getWellKnownConfiguration(String issuer); + + CompletableFuture getJwksUrl(String issuer); + + CompletableFuture> getKey(String issuer, String kid); + + CompletableFuture> getKeyFromToken(String token); + + void clearCaches(); +} diff --git a/structures-auth/src/main/java/org/kinotic/structures/auth/internal/services/DefaultJwksService.java b/structures-auth/src/main/java/org/kinotic/structures/auth/internal/services/DefaultJwksService.java new file mode 100644 index 000000000..841686cbf --- /dev/null +++ b/structures-auth/src/main/java/org/kinotic/structures/auth/internal/services/DefaultJwksService.java @@ -0,0 +1,193 @@ +/* + * + * Copyright 2008-2021 Kinotic and the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kinotic.structures.auth.internal.services; + +import java.net.URI; +import java.security.Key; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +import org.kinotic.structures.auth.api.services.JwksService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import io.jsonwebtoken.security.Jwk; +import io.jsonwebtoken.security.Jwks; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@ConditionalOnProperty(prefix = "oidc-security-service", name = "enabled", havingValue = "true", matchIfMissing = false) +public class DefaultJwksService implements JwksService { + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final Cache> keyCache; + private final Cache wellKnownCache; + + public DefaultJwksService() { + this.webClient = WebClient.builder().build(); + this.objectMapper = new ObjectMapper(); + + // Cache for individual keys, with 1 hour TTL + this.keyCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofHours(1)) + .maximumSize(100) + .build(); + + // Cache for well-known configuration, with 24 hour TTL + this.wellKnownCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofHours(24)) + .maximumSize(10) + .build(); + } + + /** + * Get the well-known configuration for an OIDC issuer + */ + public CompletableFuture getWellKnownConfiguration(String issuer) { + String cacheKey = "well-known:" + issuer; + JsonNode cached = wellKnownCache.getIfPresent(cacheKey); + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + + String wellKnownUrl = issuer + "/.well-known/openid-configuration"; + + return webClient.get() + .uri(URI.create(wellKnownUrl)) + .retrieve() + .bodyToMono(String.class) + .map(response -> { + try { + JsonNode config = objectMapper.readTree(response); + wellKnownCache.put(cacheKey, config); + return config; + } catch (Exception e) { + log.error("Failed to parse well-known configuration for issuer: {}", issuer, e); + throw new RuntimeException("Failed to parse OIDC configuration", e); + } + }) + .toFuture(); + } + + /** + * Get the JWKS URL from the well-known configuration + */ + public CompletableFuture getJwksUrl(String issuer) { + return getWellKnownConfiguration(issuer) + .thenApply(config -> { + JsonNode jwksUri = config.get("jwks_uri"); + if (jwksUri == null || jwksUri.isNull()) { + throw new RuntimeException("JWKS URI not found in OIDC configuration for issuer: " + issuer); + } + return jwksUri.asText(); + }); + } + + /** + * Get a key by its key ID (kid) + */ + public CompletableFuture> getKey(String issuer, String kid) { + String cacheKey = issuer + ":" + kid; + Jwk cachedKey = keyCache.getIfPresent(cacheKey); + if (cachedKey != null) { + return CompletableFuture.completedFuture(cachedKey); + } + + return getJwksUrl(issuer) + .thenCompose(jwksUrl -> webClient.get() + .uri(URI.create(jwksUrl)) + .retrieve() + .bodyToMono(String.class) + .toFuture()) + .thenApply(jwksResponse -> { + try { + JsonNode jwks = objectMapper.readTree(jwksResponse); + JsonNode keys = jwks.get("keys"); + + if (keys == null || !keys.isArray()) { + throw new RuntimeException("Invalid JWKS response: no keys array found"); + } + + for (JsonNode key : keys) { + String keyKid = key.get("kid") != null ? key.get("kid").asText() : null; + if (kid.equals(keyKid)) { + // Use the correct JJWT 0.12.x API for parsing RSA keys + Jwk parsedKey = Jwks.parser() + .build() + .parse(objectMapper.writeValueAsString(key)); + keyCache.put(cacheKey, parsedKey); + return parsedKey; + } + } + + throw new RuntimeException("Key with kid '" + kid + "' not found in JWKS for issuer: " + issuer); + } catch (Exception e) { + log.error("Failed to parse JWKS for issuer: {} and kid: {}", issuer, kid, e); + throw new RuntimeException("Failed to parse JWKS", e); + } + }); + } + + /** + * Extract the issuer and key ID from a JWT token header + */ + public CompletableFuture> getKeyFromToken(String token) { + try { + // Parse the JWT header to get the key ID + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new RuntimeException("Invalid JWT token format"); + } + + // Decode the header + String headerJson = new String(java.util.Base64.getUrlDecoder().decode(parts[0])); + JsonNode header = objectMapper.readTree(headerJson); + + String kid = header.get("kid") != null ? header.get("kid").asText() : null; + if (kid == null) { + throw new RuntimeException("JWT token does not contain a key ID (kid)"); + } + + // Parse the payload to get the issuer + String payloadJson = new String(java.util.Base64.getUrlDecoder().decode(parts[1])); + JsonNode payload = objectMapper.readTree(payloadJson); + String issuer = payload.get("iss") != null ? payload.get("iss").asText() : null; + if (issuer == null) { + throw new RuntimeException("JWT token does not contain an issuer (iss)"); + } + return getKey(issuer, kid); + } catch (Exception e) { + log.error("Failed to extract key information from JWT token", e); + return CompletableFuture.failedFuture(e); + } + } + + /** + * Clear all caches (useful for testing or manual cache invalidation) + */ + public void clearCaches() { + keyCache.invalidateAll(); + wellKnownCache.invalidateAll(); + } +} \ No newline at end of file diff --git a/structures-auth/src/main/java/org/kinotic/structures/auth/internal/services/OidcSecurityService.java b/structures-auth/src/main/java/org/kinotic/structures/auth/internal/services/OidcSecurityService.java new file mode 100644 index 000000000..43b0b5615 --- /dev/null +++ b/structures-auth/src/main/java/org/kinotic/structures/auth/internal/services/OidcSecurityService.java @@ -0,0 +1,335 @@ +/* + * + * Copyright 2008-2021 Kinotic and the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.kinotic.structures.auth.internal.services; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.lang.Collections; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.security.Jwk; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.kinotic.continuum.api.security.DefaultParticipant; +import org.kinotic.continuum.api.security.Participant; +import org.kinotic.continuum.api.security.ParticipantConstants; +import org.kinotic.continuum.api.security.SecurityService; +import org.kinotic.structures.auth.api.config.OidcSecurityServiceProperties; +import org.kinotic.structures.auth.api.domain.OidcProvider; +import org.kinotic.structures.auth.api.services.JwksService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.security.PublicKey; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + + +@Slf4j +@AllArgsConstructor +@Component +@ConditionalOnProperty(prefix = "oidc-security-service", name = "enabled", havingValue = "true", matchIfMissing = false) +public class OidcSecurityService implements SecurityService { + + private final OidcSecurityServiceProperties properties; + private final JwksService jwksService; + + @Override + public CompletableFuture authenticate(Map authenticationInfo) { + String authorizationHeader = getAuthorizationHeader(authenticationInfo); + if (authorizationHeader == null) { + return CompletableFuture.failedFuture(new RuntimeException("No authorization header found")); + } + + String[] parts = authorizationHeader.split(" "); + if (parts.length != 2 || !"Bearer".equalsIgnoreCase(parts[0])) { + return CompletableFuture.failedFuture(new RuntimeException("Invalid authorization header format, expected 'Bearer '")); + } + + String token = parts[1]; + return verifyJwtToken(token); + } + + private String getAuthorizationHeader(Map authenticationInfo) { + if (authenticationInfo.containsKey("authorization")) { + return authenticationInfo.get("authorization"); + } else if (authenticationInfo.containsKey("Authorization")) { + return authenticationInfo.get("Authorization"); + } + return null; + } + + private CompletableFuture verifyJwtToken(String token) { + return jwksService.getKeyFromToken(token) + .thenCompose(key -> validateTokenWithKey(token, key)); + } + + private CompletableFuture validateTokenWithKey(String token, Jwk jwk) { + try { + // Extract the actual key from the Jwk + Key key = jwk.toKey(); + + // Verify it's a PublicKey for JWT verification + if (!(key instanceof PublicKey)) { + return CompletableFuture.failedFuture(new RuntimeException("Jwk does not contain a PublicKey instance")); + } + + PublicKey publicKey = (PublicKey) key; + + // Parse and validate the JWT token + Claims claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + // Validate issuer - this must be hardcoded as email is a standard claim. + // there are standard OIDC claims for email, preferred_username, and sub. We test for each + // of those in that order. Then try a few Microsoft specific claims. + String[] emailClaims = new String[] { "email", "preferred_username", "sub", "upn", "unique_name" }; + String email = null; + for(String emailClaim : emailClaims) { + email = claims.get(emailClaim, String.class); + if(email != null && email.contains("@")) { + break; + } + } + if(email == null) { + if(email == null) { + return CompletableFuture.failedFuture(new RuntimeException("No email found in claims")); + } + } + + String issuer = claims.getIssuer(); + OidcProvider oidcProvider = isValidIssuer(issuer, email); + if (oidcProvider == null) { + return CompletableFuture.failedFuture(new RuntimeException("Invalid issuer: " + issuer)); + } + + // Validate audience + Set audiences = claims.getAudience(); + if (!isValidAudience(oidcProvider, audiences)) { + return CompletableFuture.failedFuture(new RuntimeException("Invalid audience: " + audiences)); + } + + // Validate expiration + if (claims.getExpiration() != null && claims.getExpiration().before(java.util.Date.from(Instant.now()))) { + return CompletableFuture.failedFuture(new RuntimeException("Token has expired")); + } + + // Extract roles from claims + List roles = null; + if(oidcProvider.getRolesClaimPath() != null) { + // function below will return an empty list if no roles are found at configured path + roles = extractRolesFromClaims(oidcProvider, claims); + if(roles.isEmpty()) { + log.warn("No roles found in claims, expected roles at claim path: {}", oidcProvider.getRolesClaimPath()); + return CompletableFuture.failedFuture(new RuntimeException("No roles found in claims when one expected")); + } + + if(oidcProvider.getRoles() != null && !oidcProvider.getRoles().isEmpty()) { + // we have roles and expected roles from the provider, so we need to validate the roles + if(!Collections.containsAny(roles, oidcProvider.getRoles())) { + log.warn("User roles {} do not match any required roles: {}", roles, oidcProvider.getRoles()); + return CompletableFuture.failedFuture(new RuntimeException("User does not have any required roles. Found: " + roles + ", Required: " + oidcProvider.getRoles())); + } + } + } + + // Create participant from claims + Participant participant = createParticipantFromClaims(oidcProvider, claims, roles); + return CompletableFuture.completedFuture(participant); + + } catch (JwtException e) { + log.error("JWT parsing/validation failed", e); + return CompletableFuture.failedFuture(new RuntimeException("JWT parsing/validation failed", e)); + } catch (Exception e) { + log.error("Unexpected error during JWT validation", e); + return CompletableFuture.failedFuture(new RuntimeException("Unexpected error during JWT validation", e)); + } + } + + private OidcProvider isValidIssuer(String issuer, String email) { + if (issuer == null) { + return null; + } + + OidcProvider oidcProvider = properties.getOidcProviders().stream() + .filter(provider -> issuer.equals(provider.getAuthority()) && provider.getDomains().contains(email.split("@")[1])) + .findFirst() + .orElse(null); + + if (oidcProvider == null) { + log.warn("No allowed Oidc Providers configured for issuer: {}", issuer); + return null; + } + + if(!oidcProvider.isEnabled()) { + log.warn("Oidc Provider found but is not enabled: {}", issuer); + return null; + } + + return oidcProvider; + } + + private boolean isValidAudience(OidcProvider oidcProvider, Set audiences) { + + if (oidcProvider.getAudience() == null || oidcProvider.getAudience().isEmpty()) { + log.warn("No allowed audience configured for issuer: {}", oidcProvider.getAuthority()); + return false; + } + + boolean isValid = false; + for(String audience : audiences) { + if(oidcProvider.getAudience().equals(audience)) { + isValid = true; + break; + } + } + + if(!isValid) { + log.warn("Audience is not valid: {}", audiences); + } + + return isValid; + } + + private Participant createParticipantFromClaims(OidcProvider oidcProvider, Claims claims, List roles) { + // Extract user information from claims + String subject = claims.getSubject(); + String email = claims.get("email", String.class); + String name = claims.get("name", String.class); + String preferredUsername = claims.get("preferred_username", String.class); + + // Extract tenant ID from claims or use a default + String tenantId = extractValueFromPath(claims, properties.getTenantIdFieldName(), String.class); + + if (tenantId == null) { + tenantId = "default"; // Default tenant if not specified + } + + + // TODO: for social logins, we don't get any roles.. that would need to be tracked in app metadata. This + // is where we need to store authenticated users and their roles. The first user is automatically an admin + // and can add other users as admins/users. If that is how we manage that, how do we handle the enterprise + // cases, automatically configure those users as we validate the tokens? + + // Create metadata + HashMap metadata = new HashMap<>(Map.of( + ParticipantConstants.PARTICIPANT_TYPE_METADATA_KEY, + ParticipantConstants.PARTICIPANT_TYPE_USER, + "email", email != null ? email : "", + "name", name != null ? name : (preferredUsername != null ? preferredUsername : subject), + "iss", claims.getIssuer(), + "aud", claims.getAudience().stream().collect(Collectors.joining(", ")) + )); + + if(oidcProvider.getMetadata() != null && !oidcProvider.getMetadata().isEmpty()) { + metadata.putAll(oidcProvider.getMetadata()); + } + + return new DefaultParticipant(tenantId, subject, metadata, roles); + } + + /* + * Extract roles from claims using the roles claim path. + * If the roles claim path is not provided, we do not extract roles from the JWT token. + * If the roles claim path is nested, the path is a dot-separated string of the nested properties. + * Example: "realm_access.roles" or "groups" + */ + private List extractRolesFromClaims(OidcProvider oidcProvider, Claims claims) { + if (oidcProvider.getRolesClaimPath() == null || oidcProvider.getRolesClaimPath().isEmpty()) { + return List.of(); + } + + Object rolesClaim = extractValueFromPath(claims, oidcProvider.getRolesClaimPath()); + + if (rolesClaim != null && rolesClaim instanceof List) { + @SuppressWarnings("unchecked") + List roles = (List) rolesClaim; + return roles; + } + + // Default to empty list if no roles found + return List.of(); + } + + /** + * Extracts a value from Claims using a dot-separated JSON path. + * Supports nested paths like "realm_access.roles" or "groups". + * + * @param claims The JWT claims to extract from + * @param path The dot-separated path to the desired value + * @return The value at the specified path, or null if not found + */ + private Object extractValueFromPath(Claims claims, String path) { + if (path == null || path.isEmpty()) { + return null; + } + + String[] pathParts = path.split("\\."); + Object currentValue = claims; + + for (String part : pathParts) { + if (currentValue == null) { + return null; + } + + if (currentValue instanceof Claims) { + // Extract from Claims using the part as a key + currentValue = ((Claims) currentValue).get(part); + } else if (currentValue instanceof Map) { + // Extract from Map using the part as a key + @SuppressWarnings("unchecked") + Map map = (Map) currentValue; + currentValue = map.get(part); + } else { + // If we encounter a non-Map/Claims object in the middle of the path, return null + return null; + } + } + + return currentValue; + } + + /** + * Public method to extract values from Claims using JSON paths. + * This can be used by other services that need to extract nested claim values. + * + * @param claims The JWT claims to extract from + * @param path The dot-separated path to the desired value + * @param targetType The expected type of the value + * @param The type parameter + * @return The value at the specified path cast to the target type, or null if not found or wrong type + */ + public T extractValueFromPath(Claims claims, String path, Class targetType) { + Object value = extractValueFromPath(claims, path); + + if (value != null && targetType.isInstance(value)) { + return targetType.cast(value); + } + + return null; + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/KeycloakTestBase.java b/structures-auth/src/test/java/org/kinotic/structures/auth/KeycloakTestBase.java new file mode 100644 index 000000000..0abf4ba29 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/KeycloakTestBase.java @@ -0,0 +1,13 @@ +package org.kinotic.structures.auth; + + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.kinotic.structures.auth.config.KeycloakTestContextInitializer; + +@SpringBootTest +@ContextConfiguration(initializers = KeycloakTestContextInitializer.class) +public abstract class KeycloakTestBase { + + +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/StructuresAuthTestApplication.java b/structures-auth/src/test/java/org/kinotic/structures/auth/StructuresAuthTestApplication.java new file mode 100644 index 000000000..e5edcff84 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/StructuresAuthTestApplication.java @@ -0,0 +1,25 @@ +package org.kinotic.structures.auth; + +import org.kinotic.structures.auth.config.KeycloakTestContextInitializer; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.elasticsearch.ReactiveElasticsearchClientAutoConfiguration; +import org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + + +@SpringBootApplication(exclude = {HazelcastAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class, + ReactiveElasticsearchClientAutoConfiguration.class}) +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = KeycloakTestContextInitializer.class) +@EnableConfigurationProperties +public class StructuresAuthTestApplication { + public static void main(String[] args) { + SpringApplication.run(StructuresAuthTestApplication.class, args); + } + +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/config/KeycloakTestContextInitializer.java b/structures-auth/src/test/java/org/kinotic/structures/auth/config/KeycloakTestContextInitializer.java new file mode 100644 index 000000000..d035fa6ef --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/config/KeycloakTestContextInitializer.java @@ -0,0 +1,51 @@ +package org.kinotic.structures.auth.config; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TestContextInitializer that ensures TestContainers are ready before Spring context initialization. + * This works in conjunction with TestBeanPostProcessor to provide a complete test setup. + * + * The TestBeanPostProcessor handles the detailed container setup and property injection, + * while this initializer ensures containers are started early in the context lifecycle. + */ +public class KeycloakTestContextInitializer implements ApplicationContextInitializer { + + private static final Logger log = LoggerFactory.getLogger(KeycloakTestContextInitializer.class); + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + log.info("TestContextInitializer: Ensuring TestContainers are ready before Spring context initialization..."); + + try { + // Ensure containers are started and ready before Spring context creation + // This is a lightweight check - TestBeanPostProcessor will handle the detailed setup + if (!KeyloakTestConfiguration.areContainersRunning()) { + log.info("TestContextInitializer: Starting TestContainers..."); + KeyloakTestConfiguration.startContainersSynchronously(); + } else if (!KeyloakTestConfiguration.areContainersReady()) { + log.info("TestContextInitializer: Waiting for containers to be ready..."); + KeyloakTestConfiguration.waitForContainersReady(); + } + + KeyloakTestConfiguration.ensureContainersReady(); + + log.info("TestContextInitializer: TestContainers are ready, proceeding with Spring context initialization"); + + // Keycloak OIDC properties + String keycloakAuthUrl = KeyloakTestConfiguration.getKeycloakAuthUrl(); + + // Set the dynamic Keycloak URL as an environment variable that can be referenced in YAML + // This preserves the profile-specific configurations while allowing dynamic port usage + System.setProperty("keycloak.test.url", keycloakAuthUrl); + + + } catch (Exception e) { + log.error("TestContextInitializer: Failed to ensure TestContainers are ready", e); + throw new RuntimeException("TestContainers failed to start during context initialization", e); + } + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/config/KeyloakTestConfiguration.java b/structures-auth/src/test/java/org/kinotic/structures/auth/config/KeyloakTestConfiguration.java new file mode 100644 index 000000000..15adce661 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/config/KeyloakTestConfiguration.java @@ -0,0 +1,272 @@ +package org.kinotic.structures.auth.config; + +import java.time.Duration; +import java.io.File; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.util.ResourceUtils; +import org.testcontainers.containers.wait.strategy.Wait; + +import dasniko.testcontainers.keycloak.KeycloakContainer; + +import org.kinotic.structures.auth.tests.config.ContainerHealthChecker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Component +@Profile("test") +public class KeyloakTestConfiguration { + private static final Logger log = LoggerFactory.getLogger(KeyloakTestConfiguration.class); + + public static final KeycloakContainer KEYCLOAK_CONTAINER; + + // Flag to track if containers are fully ready + private static volatile boolean containersReady = false; + private static final Object containerLock = new Object(); + + static { + + try { + log.info("Starting TestContainers..."); + + // Start Keycloak container with proper wait strategy + KEYCLOAK_CONTAINER = new KeycloakContainer("quay.io/keycloak/keycloak:22.0.5") + .withAdminUsername("admin") + .withAdminPassword("admin") + .withExposedPorts(8888) // Expose port 8888 externally + .withEnv("KEYCLOAK_ADMIN", "admin") + .withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin") + .withEnv("KC_HEALTH_ENABLED", "true") + .withEnv("KC_METRICS_ENABLED", "true") + .withEnv("KC_HTTP_PORT", "8888") // Configure Keycloak to listen on port 8888 internally + .withEnv("KC_HTTP_ENABLED", "true"); + + // Add realm import if the file exists + File realmFile = ResourceUtils.getFile("classpath:keycloak-realm-export.json"); + if (realmFile.exists()) { + log.info("Using existing Keycloak realm configuration: {}", realmFile.getAbsolutePath()); + KEYCLOAK_CONTAINER.withRealmImportFile("keycloak-realm-export.json"); + } else { + log.info("No Keycloak realm configuration found, using default configuration"); + } + + KEYCLOAK_CONTAINER.waitingFor( + Wait.forHttp("/health/ready") + .forPort(8888) // Use exposed port 8888 for health check + .withStartupTimeout(Duration.ofMinutes(3)) + ); + + // Start containers synchronously to ensure they're ready before class loading completes + startContainersSynchronously(); + + // Add shutdown hook to ensure containers are cleaned up + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + log.info("Shutting down TestContainers..."); + try { + if (KEYCLOAK_CONTAINER != null && KEYCLOAK_CONTAINER.isRunning()) { + KEYCLOAK_CONTAINER.stop(); + log.info("Keycloak container stopped"); + } + } catch (Exception e) { + log.warn("Error during container shutdown", e); + } + })); + } catch (Exception e) { + log.error("Failed to start TestContainers", e); + throw new RuntimeException("Failed to start TestContainers", e); + } + } + + /** + * Start containers synchronously and wait for them to be ready + */ + public static void startContainersSynchronously() { + log.info("Starting TestContainers synchronously..."); + + try { + + // Start Keycloak container + log.info("Starting Keycloak container..."); + KEYCLOAK_CONTAINER.start(); + log.info("Keycloak container started successfully on {}:{}", + KEYCLOAK_CONTAINER.getHost(), KEYCLOAK_CONTAINER.getMappedPort(8888)); + + // Wait for containers to be ready and healthy + waitForContainersToBeReady(); + + } catch (Exception e) { + log.error("Failed to start TestContainers", e); + throw new RuntimeException("Failed to start TestContainers", e); + } + } + + private static void waitForContainersToBeReady() { + try { + + log.info("Waiting for Keycloak to be fully operational..."); + + // Wait for Keycloak to be fully ready using the health checker + boolean keycloakReady = ContainerHealthChecker.waitForContainerHealth( + "Keycloak", + () -> ContainerHealthChecker.isKeycloakHealthy( + KEYCLOAK_CONTAINER.getHost(), + KEYCLOAK_CONTAINER.getMappedPort(8888) + ), + 30, // max attempts + 2000 // delay between attempts in ms + ); + + if (!keycloakReady) { + log.error("Keycloak failed to become ready. Container status: {}", getContainerStatus()); + throw new RuntimeException("Keycloak failed to become ready within expected time"); + } + + log.info("Keycloak is fully operational"); + + // Both containers are now ready, set the flag and notify waiting threads + synchronized (containerLock) { + containersReady = true; + containerLock.notifyAll(); + log.info("Both containers are now ready and healthy - notifying waiting threads"); + } + + } catch (Exception e) { + log.error("Failed to wait for containers to be ready. Container status: {}", getContainerStatus(), e); + throw new RuntimeException("Failed to wait for containers to be ready", e); + } + } + + /** + * Wait for containers to be ready, blocking until they are + */ + public static void waitForContainersReady() { + synchronized (containerLock) { + while (!containersReady) { + try { + log.info("Waiting for TestContainers to be ready..."); + containerLock.wait(30000); // Wait up to 10 seconds at a time + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for containers", e); + } + } + } + } + + /** + * Check if containers are ready, throwing an exception if not + */ + public static void ensureContainersReady() { + if (!containersReady) { + throw new IllegalStateException("TestContainers are not ready yet. Call waitForContainersReady() first."); + } + } + + + + public static boolean areContainersRunning() { + return KEYCLOAK_CONTAINER.isRunning(); + } + + public static boolean areContainersReady() { + return containersReady; + } + + /** + * Check if the containers are healthy and ready for testing + */ + public static boolean areContainersHealthy() { + if (!containersReady) { + return false; + } + + try { + + // Check if Keycloak is healthy + boolean keycloakHealthy = ContainerHealthChecker.isKeycloakHealthy( + KEYCLOAK_CONTAINER.getHost(), + KEYCLOAK_CONTAINER.getMappedPort(8888) + ); + + return keycloakHealthy; + + } catch (Exception e) { + log.warn("Error checking container health", e); + return false; + } + } + + /** + * Wait for containers to be healthy, blocking until they are + */ + public static void waitForContainersHealthy() { + while (!areContainersHealthy()) { + try { + log.info("Waiting for containers to become healthy..."); + Thread.sleep(10000); // Wait 10 seconds between checks + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for containers to become healthy", e); + } + } + log.info("All containers are healthy and ready for testing"); + } + + /** + * Get detailed container status information for debugging + */ + public static String getContainerStatus() { + StringBuilder status = new StringBuilder(); + status.append("Container Status:\n"); + + if (KEYCLOAK_CONTAINER != null) { + status.append("Keycloak: "); + status.append(KEYCLOAK_CONTAINER.isRunning() ? "Running" : "Not Running"); + if (KEYCLOAK_CONTAINER.isRunning()) { + status.append(" on ").append(KEYCLOAK_CONTAINER.getHost()) + .append(":").append(KEYCLOAK_CONTAINER.getMappedPort(8888)); + } + status.append("\n"); + } else { + status.append("Keycloak: Not initialized\n"); + } + + status.append("Containers Ready: ").append(containersReady); + + return status.toString(); + } + + /** + * Shutdown all TestContainers + */ + public static void shutdownContainers() { + log.info("Shutting down TestContainers..."); + + try { + + if (KEYCLOAK_CONTAINER != null && KEYCLOAK_CONTAINER.isRunning()) { + KEYCLOAK_CONTAINER.stop(); + log.info("Keycloak container stopped"); + } + + synchronized (containerLock) { + containersReady = false; + } + + log.info("All TestContainers stopped successfully"); + + } catch (Exception e) { + log.warn("Error during container shutdown", e); + } + } + + public static String getKeycloakUrl() { + return "http://" + KEYCLOAK_CONTAINER.getHost() + ":" + KEYCLOAK_CONTAINER.getMappedPort(8888); + } + + public static String getKeycloakAuthUrl() { + return getKeycloakUrl() + "/realms/test"; + } + +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/ApplicationStartupTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/ApplicationStartupTest.java new file mode 100644 index 000000000..dd43c0d03 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/ApplicationStartupTest.java @@ -0,0 +1,13 @@ +package org.kinotic.structures.auth.tests; + +import org.junit.jupiter.api.Test; +import org.kinotic.structures.auth.KeycloakTestBase; + + +public class ApplicationStartupTest extends KeycloakTestBase { + + @Test + public void applicationStarts() { + + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/AccessControlTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/AccessControlTest.java new file mode 100644 index 000000000..57e61975c --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/AccessControlTest.java @@ -0,0 +1,152 @@ +package org.kinotic.structures.auth.tests.auth; + +import org.junit.jupiter.api.Test; +import org.kinotic.continuum.api.security.Participant; +import org.kinotic.structures.auth.internal.services.OidcSecurityService; +import org.kinotic.structures.auth.KeycloakTestBase; +import org.springframework.beans.factory.annotation.Autowired; +import org.kinotic.structures.auth.config.KeyloakTestConfiguration; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for main OIDC provider (structures-client) access control validation. + * + * Tests the main provider configuration with: + * - Audiences: ["structures-client"] + * - Required roles: ["user", "admin", "poweruser"] + * + * Test scenarios: + * - Valid audience + valid roles = success + * - Valid audience + invalid roles = failure + * - Valid audience + no roles = failure + */ +public class AccessControlTest extends KeycloakTestBase { + + @Autowired + private OidcSecurityService securityService; + + // ============================================================================ + // VALID AUDIENCE + VALID ROLES = SUCCESS + // ============================================================================ + + @Test + public void testValidAudienceWithUserRole() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + String token = fetchKeycloakAccessToken("testuser@example.com", "password123", "structures-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + Participant participant = securityService.authenticate(authInfo).join(); + + assertNotNull(participant); + assertEquals("kinotic", participant.getTenantId()); + assertTrue(participant.getRoles().contains("user")); + } + + @Test + public void testValidAudienceWithAdminRole() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + String token = fetchKeycloakAccessToken("adminuser@example.com", "admin123", "structures-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + Participant participant = securityService.authenticate(authInfo).join(); + + assertNotNull(participant); + assertEquals("kinotic", participant.getTenantId()); + assertTrue(participant.getRoles().contains("admin")); + assertTrue(participant.getRoles().contains("user")); + } + + @Test + public void testValidAudienceWithPowerUserRole() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + String token = fetchKeycloakAccessToken("poweruser@example.com", "power123", "structures-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + Participant participant = securityService.authenticate(authInfo).join(); + + assertNotNull(participant); + assertEquals("kinotic", participant.getTenantId()); + assertTrue(participant.getRoles().contains("poweruser")); + } + + // ============================================================================ + // VALID AUDIENCE + INVALID/NO ROLES = FAILURE + // ============================================================================ + + @Test + public void testValidAudienceWithNoRoles() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + String token = fetchKeycloakAccessToken("noroles@example.com", "nopass123", "structures-client"); + assertNotNull(token); + + // Debug: Decode the JWT token to see what's actually in it + String[] parts = token.split("\\."); + if (parts.length == 3) { + String payload = parts[1]; + // Add padding if needed + while (payload.length() % 4 != 0) { + payload += "="; + } + String decodedPayload = new String(java.util.Base64.getDecoder().decode(payload)); + System.out.println("JWT payload for noroles@example.com: " + decodedPayload); + } + + // Debug: Check what the keycloak.test.url system property is set to + String keycloakTestUrl = System.getProperty("keycloak.test.url"); + System.out.println("keycloak.test.url system property: " + keycloakTestUrl); + + Map authInfo = Map.of("authorization", "Bearer " + token); + CompletableFuture result = securityService.authenticate(authInfo); + + while(!result.isDone()) { + Thread.sleep(1000); + } + + // Should fail because the provider requires roles but user has none + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("No roles found in claims")); + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + private String fetchKeycloakAccessToken(String username, String password, String clientId) throws Exception { + java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + String form = "username=" + java.net.URLEncoder.encode(username, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(password, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password&client_id=" + clientId; + + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(KeyloakTestConfiguration.getKeycloakUrl() + "/realms/test/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(form)) + .build(); + + java.net.http.HttpResponse response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException("Keycloak token endpoint returned status " + response.statusCode() + " for client " + clientId); + } + + com.fasterxml.jackson.databind.JsonNode node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body()); + return node.get("access_token").asText(); + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/AdminProviderAccessControlTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/AdminProviderAccessControlTest.java new file mode 100644 index 000000000..240c76eb4 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/AdminProviderAccessControlTest.java @@ -0,0 +1,146 @@ +package org.kinotic.structures.auth.tests.auth; + +import org.junit.jupiter.api.Test; +import org.kinotic.continuum.api.security.Participant; +import org.kinotic.structures.auth.internal.services.OidcSecurityService; +import org.kinotic.structures.auth.KeycloakTestBase; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.kinotic.structures.auth.config.KeyloakTestConfiguration; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for admin OIDC provider access control validation. + * + * Tests the admin provider configuration with: + * - Audiences: ["admin-client"] + * - Required roles: ["admin", "poweruser"] + * + * Test scenarios: + * - Valid audience + valid roles = success + * - Valid audience + invalid roles = failure + * - Valid audience + no roles = failure + */ +@ActiveProfiles("admin-provider") +public class AdminProviderAccessControlTest extends KeycloakTestBase { + + @Autowired + private OidcSecurityService securityService; + + @Test + public void testValidAudienceWithValidRoles() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + // Admin user with admin role should succeed + String token = fetchKeycloakAccessToken("adminuser@example.com", "admin123", "admin-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + CompletableFuture result = securityService.authenticate(authInfo); + + while(!result.isDone()) { + Thread.sleep(1000); + } + + Participant participant = result.join(); + + assertNotNull(participant); + assertEquals("kinotic", participant.getTenantId()); + assertTrue(participant.getRoles().contains("admin")); + assertTrue(participant.getRoles().contains("user")); + } + + @Test + public void testValidAudienceWithPowerUserRole() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + // Power user with poweruser role should succeed + String token = fetchKeycloakAccessToken("poweruser@example.com", "power123", "admin-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + Participant participant = securityService.authenticate(authInfo).join(); + + assertNotNull(participant); + assertEquals("kinotic", participant.getTenantId()); + assertTrue(participant.getRoles().contains("poweruser")); + } + + @Test + public void testValidAudienceWithInsufficientRoles() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + // Regular user with only 'user' role should fail (admin-client requires admin or poweruser) + String token = fetchKeycloakAccessToken("testuser@example.com", "password123", "admin-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + CompletableFuture result = securityService.authenticate(authInfo); + + while(!result.isDone()) { + Thread.sleep(1000); + } + + // Should fail because user doesn't have required roles + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("User does not have any required roles")); + } + + @Test + public void testValidAudienceWithNoRoles() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + // User with no roles should fail + String token = fetchKeycloakAccessToken("noroles@example.com", "nopass123", "admin-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + CompletableFuture result = securityService.authenticate(authInfo); + + while(!result.isDone()) { + Thread.sleep(1000); + } + + // Should fail because user has no roles + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("No roles found in claims")); + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + private String fetchKeycloakAccessToken(String username, String password, String clientId) throws Exception { + java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + String form = "username=" + java.net.URLEncoder.encode(username, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(password, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password&client_id=" + clientId; + + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(KeyloakTestConfiguration.getKeycloakUrl() + "/realms/test/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(form)) + .build(); + + java.net.http.HttpResponse response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException("Keycloak token endpoint returned status " + response.statusCode() + " for client " + clientId); + } + + com.fasterxml.jackson.databind.JsonNode node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body()); + return node.get("access_token").asText(); + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/JwksServiceTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/JwksServiceTest.java new file mode 100644 index 000000000..906144445 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/JwksServiceTest.java @@ -0,0 +1,121 @@ +package org.kinotic.structures.auth.tests.auth; + +import io.jsonwebtoken.security.Jwk; +import org.junit.jupiter.api.Test; +import org.kinotic.structures.auth.api.services.JwksService; +import org.kinotic.structures.auth.config.KeyloakTestConfiguration; +import org.kinotic.structures.auth.KeycloakTestBase; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for JwksService. + * + * Tests include: + * - JWKS key parsing + * - Cache behavior + * - Error handling + */ +public class JwksServiceTest extends KeycloakTestBase { + + @Autowired + private JwksService jwksService; + + @Test + public void testJwksServiceInitialization() { + assertNotNull(jwksService); + + // Test that caches are initialized + // Note: We can't directly access private fields, but we can test behavior + assertDoesNotThrow(() -> jwksService.clearCaches()); + } + + @Test + public void testClearCaches() { + // Test that clearing caches doesn't throw exceptions + assertDoesNotThrow(() -> jwksService.clearCaches()); + } + + @Test + public void testInvalidJwtTokenFormat() { + String invalidToken = "invalid.token.format"; + + CompletableFuture> result = jwksService.getKeyFromToken(invalidToken); + + assertTrue(result.isCompletedExceptionally()); + } + + @Test + public void testJwtTokenWithoutKid() { + // Create a JWT token without kid in header + String tokenWithoutKid = createJwtTokenWithoutKid(); + + CompletableFuture> result = jwksService.getKeyFromToken(tokenWithoutKid); + + assertTrue(result.isCompletedExceptionally()); + } + + @Test + public void testJwtTokenWithoutIssuer() { + // Create a JWT token without issuer in payload + String tokenWithoutIssuer = createJwtTokenWithoutIssuer(); + + CompletableFuture> result = jwksService.getKeyFromToken(tokenWithoutIssuer); + + assertTrue(result.isCompletedExceptionally()); + } + + // It will be re-enabled once the container initialization issues are resolved + @Test + public void testFetchKeyFromValidToken() throws Exception { + // Use testcontainer Keycloak started by TestConfiguration + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + String token = fetchKeycloakAccessToken("testuser@example.com", "password123"); + assertNotNull(token); + CompletableFuture> result = jwksService.getKeyFromToken(token); + assertDoesNotThrow(() -> result.join()); + assertNotNull(result.join()); + } + + // Helper methods for creating test JWT tokens + private static String createJwtTokenWithoutKid() { + // Create a JWT token with header that doesn't contain 'kid' + String header = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"; // Base64 encoded {"alg":"RS256","typ":"JWT"} + String payload = "eyJpc3MiOiJodHRwczovL3Rlc3QtZXhhbXBsZS5jb20iLCJzdWIiOiJ0ZXN0LXVzZXIifQ"; // Base64 encoded {"iss":"https://test-example.com","sub":"test-user"} + String signature = "dummy-signature"; + + return header + "." + payload + "." + signature; + } + + private static String createJwtTokenWithoutIssuer() { + // Create a JWT token with payload that doesn't contain 'iss' + String header = "eyJraWQiOiJ0ZXN0LWtleSIsImFsZyI6IlJTMjU2IiwidHlwIjoiSldUIn0"; // Base64 encoded {"kid":"test-key","alg":"RS256","typ":"JWT"} + String payload = "eyJzdWIiOiJ0ZXN0LXVzZXIifQ"; // Base64 encoded {"sub":"test-user"} + String signature = "dummy-signature"; + + return header + "." + payload + "." + signature; + } + + private String fetchKeycloakAccessToken(String username, String password) throws Exception { + java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + String form = "username=" + java.net.URLEncoder.encode(username, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(password, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password&client_id=structures-client"; + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(KeyloakTestConfiguration.getKeycloakUrl() + "/realms/test/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(form)) + .build(); + java.net.http.HttpResponse response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException("Keycloak token endpoint returned status " + response.statusCode()); + } + com.fasterxml.jackson.databind.JsonNode node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body()); + return node.get("access_token").asText(); + } + +} \ No newline at end of file diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/OidcAuthVerifierTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/OidcAuthVerifierTest.java new file mode 100644 index 000000000..fc37ddd35 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/OidcAuthVerifierTest.java @@ -0,0 +1,146 @@ +package org.kinotic.structures.auth.tests.auth; + +import org.junit.jupiter.api.Test; +import org.kinotic.continuum.api.security.Participant; +import org.kinotic.structures.auth.internal.services.OidcSecurityService; +import org.kinotic.structures.auth.KeycloakTestBase; +import org.springframework.beans.factory.annotation.Autowired; +import org.kinotic.structures.auth.config.KeyloakTestConfiguration; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for OIDC authentication verifier. + * + * Tests include: + * - Authorization header parsing + * - Configuration validation + * - Error handling scenarios + */ +public class OidcAuthVerifierTest extends KeycloakTestBase { + + @Autowired + private OidcSecurityService securityService; + + + @Test + public void testNoAuthorizationHeader() { + Map authInfo = Map.of(); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + assertThrows(CompletionException.class, () -> result.join()); + } + + @Test + public void testInvalidAuthorizationHeader() { + Map authInfo = Map.of("authorization", "InvalidFormat"); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + assertThrows(CompletionException.class, () -> result.join()); + } + + @Test + public void testNonBearerAuthorizationHeader() { + Map authInfo = Map.of("authorization", "Basic dXNlcjpwYXNz"); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + assertThrows(CompletionException.class, () -> result.join()); + } + + @Test + public void testCaseInsensitiveAuthorizationHeader() { + Map authInfo = Map.of("Authorization", "Bearer valid.jwt.token"); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + assertThrows(CompletionException.class, () -> result.join()); + } + + @Test + public void testValidBearerHeaderWithInvalidToken() { + Map authInfo = Map.of("authorization", "Bearer invalid.jwt.token"); + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + assertThrows(CompletionException.class, () -> result.join()); + } + + @Test + public void testNoAuthorizationHeaderExceptionDetails() { + Map authInfo = Map.of(); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertEquals("No authorization header found", exception.getCause().getMessage()); + } + + @Test + public void testInvalidAuthorizationHeaderExceptionDetails() { + Map authInfo = Map.of("authorization", "InvalidFormat"); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertEquals("Invalid authorization header format, expected 'Bearer '", exception.getCause().getMessage()); + } + + @Test + public void testNonBearerAuthorizationHeaderExceptionDetails() { + Map authInfo = Map.of("authorization", "Basic dXNlcjpwYXNz"); + + CompletableFuture result = securityService.authenticate(authInfo); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertEquals("Invalid authorization header format, expected 'Bearer '", exception.getCause().getMessage()); + } + + @Test + public void testValidTokenFromKeycloak() throws Exception { + // Use testcontainer Keycloak started by TestConfiguration + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + String token = fetchKeycloakAccessToken("testuser@example.com", "password123"); + assertNotNull(token); + Map authInfo = Map.of("authorization", "Bearer " + token); + Participant participant = securityService.authenticate(authInfo).join(); + assertNotNull(participant); + assertEquals("kinotic", participant.getTenantId()); + } + + private String fetchKeycloakAccessToken(String username, String password) throws Exception { + java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + String form = "username=" + java.net.URLEncoder.encode(username, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(password, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password&client_id=structures-client"; + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(KeyloakTestConfiguration.getKeycloakUrl() + "/realms/test/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(form)) + .build(); + java.net.http.HttpResponse response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException("Keycloak token endpoint returned status " + response.statusCode()); + } + com.fasterxml.jackson.databind.JsonNode node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body()); + return node.get("access_token").asText(); + } + +} \ No newline at end of file diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/UnauthorizedProviderAccessControlTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/UnauthorizedProviderAccessControlTest.java new file mode 100644 index 000000000..a84b5018e --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/auth/UnauthorizedProviderAccessControlTest.java @@ -0,0 +1,101 @@ +package org.kinotic.structures.auth.tests.auth; + +import org.junit.jupiter.api.Test; +import org.kinotic.continuum.api.security.Participant; +import org.kinotic.structures.auth.internal.services.OidcSecurityService; +import org.kinotic.structures.auth.KeycloakTestBase; +import org.springframework.beans.factory.annotation.Autowired; +import org.kinotic.structures.auth.config.KeyloakTestConfiguration; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test class for unauthorized OIDC provider access control validation. + * + * Tests the unauthorized provider configuration with: + * - Audiences: ["unauthorized-client"] + * - Required roles: ["user"] + * + * This provider is NOT in the main application's allowed audiences, + * so it should always fail audience validation. + */ +public class UnauthorizedProviderAccessControlTest extends KeycloakTestBase { + + @Autowired + private OidcSecurityService securityService; + + @Test + public void testUnauthorizedClientAlwaysFails() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + // Even with valid user and roles, unauthorized-client should fail + String token = fetchKeycloakAccessToken("testuser@example.com", "password123", "unauthorized-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + CompletableFuture result = securityService.authenticate(authInfo); + + while(!result.isDone()) { + Thread.sleep(1000); + } + + // Should fail because unauthorized-client is not in allowed audiences + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("Invalid audience")); + } + + @Test + public void testUnauthorizedClientWithAdminUserFails() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(KeyloakTestConfiguration.KEYCLOAK_CONTAINER != null, + "Keycloak container not available"); + + // Even admin user with admin-client should fail + String token = fetchKeycloakAccessToken("adminuser@example.com", "admin123", "unauthorized-client"); + assertNotNull(token); + + Map authInfo = Map.of("authorization", "Bearer " + token); + CompletableFuture result = securityService.authenticate(authInfo); + + while(!result.isDone()) { + Thread.sleep(1000); + } + + // Should fail because unauthorized-client is not in allowed audiences + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, () -> result.join()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("Invalid audience")); + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + private String fetchKeycloakAccessToken(String username, String password, String clientId) throws Exception { + java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient(); + String form = "username=" + java.net.URLEncoder.encode(username, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(password, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password&client_id=" + clientId; + + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(KeyloakTestConfiguration.getKeycloakUrl() + "/realms/test/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(java.net.http.HttpRequest.BodyPublishers.ofString(form)) + .build(); + + java.net.http.HttpResponse response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException("Keycloak token endpoint returned status " + response.statusCode() + " for client " + clientId); + } + + com.fasterxml.jackson.databind.JsonNode node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.body()); + return node.get("access_token").asText(); + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/ContainerHealthChecker.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/ContainerHealthChecker.java new file mode 100644 index 000000000..18ced4c6a --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/ContainerHealthChecker.java @@ -0,0 +1,127 @@ +package org.kinotic.structures.auth.tests.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.URI; +import java.time.Duration; + +/** + * Utility class for checking container health status + */ +public class ContainerHealthChecker { + + private static final Logger log = LoggerFactory.getLogger(ContainerHealthChecker.class); + + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + /** + * Check if Elasticsearch is healthy and ready + */ + public static boolean isElasticsearchHealthy(String host, int port) { + String healthUrl = String.format("http://%s:%d/_cluster/health", host, port); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(healthUrl)) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, + HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + String body = response.body(); + // Check if cluster is in a healthy state (green or yellow) + return body.contains("\"status\":\"green\"") || + body.contains("\"status\":\"yellow\""); + } + + log.debug("Elasticsearch health check returned status: {}", response.statusCode()); + return false; + + } catch (Exception e) { + log.debug("Elasticsearch health check failed: {}", e.getMessage()); + return false; + } + } + + /** + * Check if Keycloak is healthy and ready + */ +public static boolean isKeycloakHealthy(String host, int port) { + String healthUrl = String.format("http://%s:%d/health/ready", host, port); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(healthUrl)) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, + HttpResponse.BodyHandlers.ofString()); + + boolean isHealthy = response.statusCode() == 200; + if (isHealthy) { + log.debug("Keycloak health check successful"); + } else { + log.debug("Keycloak health check returned status: {}", response.statusCode()); + } + + return isHealthy; + + } catch (Exception e) { + log.debug("Keycloak health check failed: {}", e.getMessage()); + return false; + } + } + + /** + * Wait for a container to become healthy with retry logic + */ + public static boolean waitForContainerHealth( + String containerName, + HealthCheckFunction healthCheck, + int maxAttempts, + long delayMs) { + + log.info("Waiting for {} to become healthy...", containerName); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + if (healthCheck.check()) { + log.info("{} is healthy after {} attempts", containerName, attempt); + return true; + } + + if (attempt < maxAttempts) { + log.debug("{} health check attempt {} failed, retrying in {} ms...", + containerName, attempt, delayMs); + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Interrupted while waiting for {} to become healthy", containerName); + return false; + } + } + } + + log.error("{} failed to become healthy after {} attempts", containerName, maxAttempts); + return false; + } + + /** + * Functional interface for health checks + */ + @FunctionalInterface + public interface HealthCheckFunction { + boolean check(); + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/ContainerHealthCheckerTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/ContainerHealthCheckerTest.java new file mode 100644 index 000000000..2f92d481a --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/ContainerHealthCheckerTest.java @@ -0,0 +1,66 @@ +package org.kinotic.structures.auth.tests.config; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.*; + +public class ContainerHealthCheckerTest { + + private static final Logger log = LoggerFactory.getLogger(ContainerHealthCheckerTest.class); + + @Test + public void testElasticsearchHealthCheckWithInvalidHost() { + // Test that Elasticsearch health check handles invalid host gracefully + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy("invalid-host", 9200); + assertFalse(isHealthy, "Health check should return false for invalid host"); + } + + @Test + public void testKeycloakHealthCheckWithInvalidHost() { + // Test that Keycloak health check handles invalid host gracefully + boolean isHealthy = ContainerHealthChecker.isKeycloakHealthy("invalid-host", 8080); + assertFalse(isHealthy, "Health check should return false for invalid host"); + } + + @Test + public void testHealthCheckWithInvalidHost() { + // Test health check with invalid host (should return false, not throw exception) + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy("invalid-host", 9200); + assertFalse(isHealthy, "Health check should return false for invalid host"); + } + + @Test + public void testHealthCheckWithInvalidPort() { + // Test health check with invalid port (should return false, not throw exception) + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy("localhost", 9999); + assertFalse(isHealthy, "Health check should return false for invalid port"); + } + + @Test + public void testWaitForContainerHealth() { + // Test the wait mechanism with a simple health check + boolean result = ContainerHealthChecker.waitForContainerHealth( + "TestContainer", + () -> true, // Always return true + 1, // Only 1 attempt needed + 100 // 100ms delay + ); + + assertTrue(result, "Wait should succeed for always-healthy container"); + } + + @Test + public void testWaitForContainerHealthWithFailure() { + // Test the wait mechanism with a failing health check + boolean result = ContainerHealthChecker.waitForContainerHealth( + "TestContainer", + () -> false, // Always return false + 3, // 3 attempts + 100 // 100ms delay + ); + + assertFalse(result, "Wait should fail for always-unhealthy container"); + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/TestConfigurationTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/TestConfigurationTest.java new file mode 100644 index 000000000..8a9302565 --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/config/TestConfigurationTest.java @@ -0,0 +1,66 @@ +package org.kinotic.structures.auth.tests.config; + +import org.junit.jupiter.api.Test; +import org.kinotic.structures.auth.config.KeyloakTestConfiguration; +import org.kinotic.structures.auth.KeycloakTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestConfigurationTest extends KeycloakTestBase { + + @Test + public void testTestContainersStarted() { + // Verify that TestContainers are running + assertNotNull(KeyloakTestConfiguration.KEYCLOAK_CONTAINER); + assertTrue(KeyloakTestConfiguration.areContainersRunning()); + assertTrue(KeyloakTestConfiguration.areContainersReady()); + } + + @Test + public void testContainersAreRunning() { + // Containers should already be ready from TestBase.setUp() + assertTrue(KeyloakTestConfiguration.areContainersRunning(), "Containers should be running"); + assertTrue(KeyloakTestConfiguration.areContainersReady(), "Containers should be ready"); + } + + + @Test + public void testKeycloakUrl() { + String url = KeyloakTestConfiguration.getKeycloakUrl(); + assertNotNull(url); + assertTrue(url.startsWith("http://")); + assertTrue(url.contains(":")); + } + + @Test + public void testKeycloakAuthUrl() { + String url = KeyloakTestConfiguration.getKeycloakAuthUrl(); + assertNotNull(url); + assertTrue(url.startsWith("http://")); + assertTrue(url.endsWith("/realms/test")); + } + + @Test + public void testContainersAreHealthy() { + // Containers should already be healthy from TestBase.setUp() + assertTrue(KeyloakTestConfiguration.areContainersHealthy(), "Containers should be healthy"); + } + + @Test + public void testContainerStatus() { + String status = KeyloakTestConfiguration.getContainerStatus(); + assertNotNull(status); + assertTrue(status.contains("Keycloak")); + assertTrue(status.contains("Containers Ready: true")); + } + + @Test + public void testKeycloakHealthCheck() { + // Test that Keycloak health check works + boolean isHealthy = ContainerHealthChecker.isKeycloakHealthy( + KeyloakTestConfiguration.KEYCLOAK_CONTAINER.getHost(), + KeyloakTestConfiguration.KEYCLOAK_CONTAINER.getMappedPort(8888) + ); + assertTrue(isHealthy, "Keycloak should be healthy"); + } +} diff --git a/structures-auth/src/test/java/org/kinotic/structures/auth/tests/services/OidcSecurityServiceTest.java b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/services/OidcSecurityServiceTest.java new file mode 100644 index 000000000..5cd30238b --- /dev/null +++ b/structures-auth/src/test/java/org/kinotic/structures/auth/tests/services/OidcSecurityServiceTest.java @@ -0,0 +1,146 @@ +package org.kinotic.structures.auth.tests.services; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kinotic.structures.auth.api.config.OidcSecurityServiceProperties; +import org.kinotic.structures.auth.api.services.JwksService; +import org.kinotic.structures.auth.internal.services.OidcSecurityService; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test class for OidcSecurityService path extraction functionality. + */ +class OidcSecurityServiceTest { + + private OidcSecurityService oidcSecurityService; + private Claims testClaims; + + @Mock + private OidcSecurityServiceProperties properties; + + @Mock + private JwksService jwksService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + oidcSecurityService = new OidcSecurityService(properties, jwksService); + + // Create a mock Claims object for testing + testClaims = mock(Claims.class); + + // Set up the mock to return test data + Map realmAccess = new HashMap<>(); + realmAccess.put("roles", List.of("admin", "user")); + + Map groups = new HashMap<>(); + groups.put("department", "engineering"); + groups.put("level", "senior"); + + // Mock the Claims methods for step-by-step path traversal + when(testClaims.get("email", String.class)).thenReturn("test@example.com"); + when(testClaims.get("name", String.class)).thenReturn("Test User"); + when(testClaims.get("realm_access")).thenReturn(realmAccess); + when(testClaims.get("groups")).thenReturn(groups); + when(testClaims.get("permissions")).thenReturn(List.of("read", "write")); + + // Mock the get method without type parameter for nested traversal + when(testClaims.get("email")).thenReturn("test@example.com"); + when(testClaims.get("name")).thenReturn("Test User"); + when(testClaims.get("realm_access")).thenReturn(realmAccess); + when(testClaims.get("groups")).thenReturn(groups); + when(testClaims.get("permissions")).thenReturn(List.of("read", "write")); + } + + @Test + void testExtractValueFromPath_SimplePath() { + // Test simple path extraction + String email = oidcSecurityService.extractValueFromPath(testClaims, "email", String.class); + assertEquals("test@example.com", email); + + String name = oidcSecurityService.extractValueFromPath(testClaims, "name", String.class); + assertEquals("Test User", name); + } + + @Test + void testExtractValueFromPath_NestedPath() { + // Test nested path extraction + @SuppressWarnings("unchecked") + List roles = oidcSecurityService.extractValueFromPath(testClaims, "realm_access.roles", List.class); + assertNotNull(roles); + assertEquals(2, roles.size()); + assertTrue(roles.contains("admin")); + assertTrue(roles.contains("user")); + } + + @Test + void testExtractValueFromPath_DeeperNestedPath() { + // Test deeper nested path extraction + String department = oidcSecurityService.extractValueFromPath(testClaims, "groups.department", String.class); + assertEquals("engineering", department); + + String level = oidcSecurityService.extractValueFromPath(testClaims, "groups.level", String.class); + assertEquals("senior", level); + } + + @Test + void testExtractValueFromPath_ListPath() { + // Test direct list path extraction + @SuppressWarnings("unchecked") + List permissions = oidcSecurityService.extractValueFromPath(testClaims, "permissions", List.class); + assertNotNull(permissions); + assertEquals(2, permissions.size()); + assertTrue(permissions.contains("read")); + assertTrue(permissions.contains("write")); + } + + @Test + void testExtractValueFromPath_NonExistentPath() { + // Test non-existent path + Object result = oidcSecurityService.extractValueFromPath(testClaims, "non.existent.path", Object.class); + assertNull(result); + } + + @Test + void testExtractValueFromPath_EmptyPath() { + // Test empty path + Object result = oidcSecurityService.extractValueFromPath(testClaims, "", Object.class); + assertNull(result); + + result = oidcSecurityService.extractValueFromPath(testClaims, null, Object.class); + assertNull(result); + } + + @Test + void testExtractValueFromPath_TypeMismatch() { + // Test type mismatch + String result = oidcSecurityService.extractValueFromPath(testClaims, "permissions", String.class); + assertNull(result); // Should return null for type mismatch + } + + @Test + void testExtractValueFromPath_ComplexNestedStructure() { + // Test with more complex nested structure + Map complexNested = new HashMap<>(); + Map level1 = new HashMap<>(); + Map level2 = new HashMap<>(); + level2.put("value", "deep_value"); + level1.put("level2", level2); + complexNested.put("level1", level1); + + when(testClaims.get("complex")).thenReturn(complexNested); + + String deepValue = oidcSecurityService.extractValueFromPath(testClaims, "complex.level1.level2.value", String.class); + assertEquals("deep_value", deepValue); + } +} diff --git a/structures-auth/src/test/resources/application-admin-provider.yml b/structures-auth/src/test/resources/application-admin-provider.yml new file mode 100644 index 000000000..42de71289 --- /dev/null +++ b/structures-auth/src/test/resources/application-admin-provider.yml @@ -0,0 +1,21 @@ + +# OIDC security service configuration for admin provider testing +oidc-security-service: + enabled: true + debug: true + oidc-providers: + - provider: "keycloak-admin" + display-name: "Keycloak Admin" + enabled: true + roles-claim-path: "realm_access.roles" + domains: + - "example.com" + audience: "admin-client" + client-id: "admin-client" + authority: "${keycloak.test.url:http://localhost:8888/realms/test}" + redirect-uri: "http://localhost:8989/admin/login" + post-logout-redirect-uri: "http://localhost:8989/admin" + silent-redirect-uri: "http://localhost:8989/admin/login/silent-renew" + roles: + - "admin" + - "poweruser" diff --git a/structures-auth/src/test/resources/application-unauthorized-provider.yml b/structures-auth/src/test/resources/application-unauthorized-provider.yml new file mode 100644 index 000000000..99e71e6a1 --- /dev/null +++ b/structures-auth/src/test/resources/application-unauthorized-provider.yml @@ -0,0 +1,20 @@ + +# OIDC security service configuration for unauthorized provider testing +oidc-security-service: + enabled: true + debug: true + oidc-providers: + - provider: "keycloak-unauthorized" + display-name: "Keycloak Unauthorized" + enabled: true + roles-claim-path: "realm_access.roles" + domains: + - "example.com" + audience: "unauthorized-client" + client-id: "unauthorized-client" + authority: "${keycloak.test.url:http://localhost:8888/realms/test}" + redirect-uri: "http://localhost:8989/unauthorized/login" + post-logout-redirect-uri: "http://localhost:8989/unauthorized" + silent-redirect-uri: "http://localhost:8989/unauthorized/login/silent-renew" + roles: + - "user" diff --git a/structures-auth/src/test/resources/application.yml b/structures-auth/src/test/resources/application.yml new file mode 100644 index 000000000..b42518961 --- /dev/null +++ b/structures-auth/src/test/resources/application.yml @@ -0,0 +1,69 @@ +spring: + ai: + openai: + api-key: "test-key" + base-url: https://api.x.ai + chat: + options: + model: grok-4 + main: + allow-circular-references: true + web-application-type: none + allow-bean-definition-overriding: true + + +continuum: + discovery: sharedfs + debug: true + maxNumberOfCoresToUse: 4 + +logging: + level: + org: + springframework: + web: DEBUG + kinotic: TRACE + io: + vertx: DEBUG + +server: + port: 8989 + + +# OIDC security service configuration +oidc-security-service: + enabled: true + debug: true + oidc-providers: + - provider: "keycloak" + display-name: "Keycloak" + enabled: true + roles-claim-path: "realm_access.roles" + domains: + - "example.com" + audience: "structures-client" + client-id: "structures-client" + authority: "${keycloak.test.url:http://localhost:8888/realms/test}" + redirect-uri: "http://localhost:8989/login" + post-logout-redirect-uri: "http://localhost:8989" + silent-redirect-uri: "http://localhost:8989/login/silent-renew" + roles: + - "user" + - "admin" + - "poweruser" + - provider: "keycloak-admin" + display-name: "Keycloak Admin" + enabled: true + roles-claim-path: "realm_access.roles" + domains: + - "admin.example.com" + audience: "admin-client" + client-id: "admin-client" + authority: "${keycloak.test.url:http://localhost:8888/realms/test}" + redirect-uri: "http://localhost:8989/admin/login" + post-logout-redirect-uri: "http://localhost:8989/admin" + silent-redirect-uri: "http://localhost:8989/admin/login/silent-renew" + roles: + - "admin" + - "poweruser" + diff --git a/structures-auth/src/test/resources/keycloak-realm-export.json b/structures-auth/src/test/resources/keycloak-realm-export.json new file mode 100644 index 000000000..e2b54e0c6 --- /dev/null +++ b/structures-auth/src/test/resources/keycloak-realm-export.json @@ -0,0 +1,421 @@ +{ + "realm": "test", + "enabled": true, + "clients": [ + { + "clientId": "structures-client", + "name": "Structures Application", + "description": "Structures frontend application", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:5173/login", + "http://localhost:5173/login/silent-renew" + ], + "webOrigins": [ + "http://localhost:5173" + ], + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "protocol": "openid-connect", + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String" + } + }, + { + "name": "client-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "claim.name": "aud", + "claim.value": "structures-client", + "jsonType.label": "String" + } + }, + { + "name": "tenantId", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "config": { + "claim.value": "kinotic", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "tenantId", + "jsonType.label": "String" + } + } + ] + }, + { + "clientId": "admin-client", + "name": "Admin Application", + "description": "Admin-only application", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:5174/login" + ], + "webOrigins": [ + "http://localhost:5174" + ], + "protocol": "openid-connect", + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String" + } + }, + { + "name": "client-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "claim.name": "aud", + "claim.value": "admin-client", + "jsonType.label": "String" + } + }, + { + "name": "tenantId", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "config": { + "claim.value": "kinotic", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "tenantId", + "jsonType.label": "String" + } + } + ] + }, + { + "clientId": "unauthorized-client", + "name": "Unauthorized Application", + "description": "Application not in allowed audiences", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:5175/login" + ], + "webOrigins": [ + "http://localhost:5175" + ], + "protocol": "openid-connect", + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String" + } + }, + { + "name": "client-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "claim.name": "aud", + "claim.value": "unauthorized-client", + "jsonType.label": "String" + } + } + ] + } + ], + "users": [ + { + "username": "testuser@example.com", + "enabled": true, + "emailVerified": true, + "firstName": "Test", + "lastName": "User", + "email": "testuser@example.com", + "credentials": [ + { + "type": "password", + "value": "password123", + "temporary": false + } + ], + "realmRoles": ["user"] + }, + { + "username": "adminuser@example.com", + "enabled": true, + "emailVerified": true, + "firstName": "Admin", + "lastName": "User", + "email": "adminuser@example.com", + "credentials": [ + { + "type": "password", + "value": "admin123", + "temporary": false + } + ], + "realmRoles": ["admin", "user"] + }, + { + "username": "poweruser@example.com", + "enabled": true, + "emailVerified": true, + "firstName": "Power", + "lastName": "User", + "email": "poweruser@example.com", + "credentials": [ + { + "type": "password", + "value": "power123", + "temporary": false + } + ], + "realmRoles": ["poweruser"] + }, + { + "username": "noroles@example.com", + "enabled": true, + "emailVerified": true, + "firstName": "No", + "lastName": "Roles", + "email": "noroles@example.com", + "credentials": [ + { + "type": "password", + "value": "nopass123", + "temporary": false + } + ], + "realmRoles": [] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user role" + }, + { + "name": "admin", + "description": "Administrator role" + }, + { + "name": "poweruser", + "description": "Power user role with extended permissions" + } + ] + } +} diff --git a/structures-core/README.md b/structures-core/README.md new file mode 100644 index 000000000..db394813a --- /dev/null +++ b/structures-core/README.md @@ -0,0 +1,154 @@ +# Structures Core Library + +The core library that provides the foundational data storage, retrieval, and management capabilities for the Structures framework. + +## Overview + +Structures Core is the heart of the Structures framework, providing: +- **Data Storage Engine**: Flexible schema-based data storage with support for multiple backends +- **Schema Management**: Dynamic schema evolution and versioning +- **Query Engine**: Powerful querying capabilities with GraphQL and REST support +- **Security Framework**: Authentication and authorization services +- **Plugin System**: Extensible architecture for custom functionality + +## Features + +- **Schema Evolution**: Dynamic schema changes without data migration +- **Multi-tenant Support**: Built-in tenant isolation and management +- **GraphQL API**: Native GraphQL support with schema introspection +- **REST API**: Comprehensive REST endpoints for all operations +- **Plugin Architecture**: Extensible system for custom data types and operations +- **Audit Trail**: Complete audit logging for all data operations +- **Search Integration**: Full-text search capabilities with Elasticsearch + +## Dependencies + +- **Spring Boot**: Core framework and dependency injection +- **GraphQL**: Query language and execution engine +- **Elasticsearch**: Search and indexing backend +- **Jackson**: JSON processing and serialization +- **Spring Security**: Authentication and authorization + +## Quick Start + +### 1. Add the dependency + +```gradle +implementation project(':structures-core') +``` + +### 2. Enable auto-configuration + +```java +@SpringBootApplication +@EnableStructuresCore +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} +``` + +### 3. Configure your data source + +```yaml +structures: + core: + elasticsearch: + connections: + - scheme: http + host: localhost + port: 9200 + index-prefix: struct_ + tenant-id-field: structuresTenantId +``` + +## Core Components + +### Data Management +- **StructureService**: Core data operations (create, read, update, delete) +- **SchemaService**: Schema definition and evolution management +- **QueryService**: Advanced querying and filtering capabilities + +### Security +- **SecurityService**: Authentication and authorization +- **TenantService**: Multi-tenant isolation and management + +### Extensions +- **PluginManager**: Plugin system for custom functionality +- **DecoratorRegistry**: Custom decorators for data transformation + +## API Reference + +### StructureService +- `createStructure(String tenantId, String schemaId, Map data)` +- `getStructure(String tenantId, String structureId)` +- `updateStructure(String tenantId, String structureId, Map data)` +- `deleteStructure(String tenantId, String structureId)` +- `queryStructures(String tenantId, String schemaId, QueryCriteria criteria)` + +### SchemaService +- `createSchema(String tenantId, SchemaDefinition schema)` +- `getSchema(String tenantId, String schemaId)` +- `updateSchema(String tenantId, String schemaId, SchemaDefinition schema)` +- `deleteSchema(String tenantId, String schemaId)` + +## Configuration + +### Elasticsearch Configuration +```yaml +structures: + core: + elasticsearch: + connections: + - scheme: http + host: elasticsearch + port: 9200 + username: elastic + password: changeme + connection-timeout: 5s + socket-timeout: 60s + index-prefix: struct_ + tenant-id-field: structuresTenantId +``` + +### GraphQL Configuration +```yaml +structures: + core: + graphql: + port: 4000 + path: /graphql/ + cors: + allowed-origins: "*" +``` + +### Security Configuration +```yaml +structures: + core: + security: + openapi: + security-type: BASIC + port: 8080 + path: /api/ +``` + +## Testing + +The library includes comprehensive tests: + +```bash +./gradlew :structures-core:test +``` + +## Contributing + +1. Follow the existing code conventions +2. Add tests for new functionality +3. Update documentation as needed +4. Ensure backward compatibility for schema changes + +## License + +This library is part of the Structures framework and follows the same licensing terms. diff --git a/structures-core/build.gradle b/structures-core/build.gradle index 7a2a368c2..bec877680 100644 --- a/structures-core/build.gradle +++ b/structures-core/build.gradle @@ -5,6 +5,7 @@ plugins { dependencies { implementation project(':structures-sql') + implementation project(':structures-auth') implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-json' diff --git a/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/StructuresVerticleFactory.java b/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/StructuresVerticleFactory.java index 0212d2918..e4f767c87 100644 --- a/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/StructuresVerticleFactory.java +++ b/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/StructuresVerticleFactory.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.kinotic.continuum.api.security.SecurityService; import org.kinotic.structures.api.config.StructuresProperties; +import org.kinotic.structures.auth.api.config.OidcSecurityServiceProperties; import org.kinotic.structures.internal.endpoints.graphql.DelegatingGqlHandler; import org.kinotic.structures.internal.endpoints.graphql.GqlVerticle; import org.kinotic.structures.internal.endpoints.openapi.OpenApiVerticle; @@ -31,6 +32,9 @@ public class StructuresVerticleFactory { // Web Server Deps private final HealthChecks healthChecks; + private final OidcSecurityServiceProperties oidcSecurityServiceProperties; + + public GqlVerticle createGqlVerticle(){ return new GqlVerticle(delegatingGqlHandler, properties, securityService); } @@ -44,7 +48,6 @@ public WebServerVerticle createWebServerVerticle(){ } public WebServerNextVerticle createWebServerNextVerticle(){ - return new WebServerNextVerticle(healthChecks, properties); + return new WebServerNextVerticle(healthChecks, properties, oidcSecurityServiceProperties); } - } diff --git a/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/WebServerNextVerticle.java b/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/WebServerNextVerticle.java index 399915016..3215772a9 100644 --- a/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/WebServerNextVerticle.java +++ b/structures-core/src/main/java/org/kinotic/structures/internal/endpoints/WebServerNextVerticle.java @@ -1,25 +1,38 @@ package org.kinotic.structures.internal.endpoints; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import io.vertx.core.AbstractVerticle; import io.vertx.core.Promise; import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerResponse; import io.vertx.ext.healthchecks.HealthCheckHandler; import io.vertx.ext.healthchecks.HealthChecks; import io.vertx.ext.web.Route; import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.CorsHandler; import io.vertx.ext.web.handler.StaticHandler; import lombok.RequiredArgsConstructor; import org.kinotic.structures.api.config.StructuresProperties; +import org.kinotic.structures.auth.api.config.OidcSecurityServiceProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Created by Navíd Mitchell 🤪on 6/8/23. + * Web server verticle for serving the modern frontend (structures-frontend-next). + * Integrates with FrontendConfigurationService to provide dynamic configuration. */ @RequiredArgsConstructor -public class WebServerNextVerticle extends AbstractVerticle{ +public class WebServerNextVerticle extends AbstractVerticle { + + private static final Logger logger = LoggerFactory.getLogger(WebServerNextVerticle.class); + private static final ObjectMapper objectMapper = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT); private final HealthChecks healthChecks; private final StructuresProperties properties; + private final OidcSecurityServiceProperties oidcSecurityServiceProperties; private HttpServer server; @Override @@ -31,7 +44,7 @@ public void start(Promise startPromise) { String allowedOriginPattern = properties.getCorsAllowedOriginPattern(); if ("*".equals(allowedOriginPattern)) { allowedOriginPattern = ".*"; - } + } CorsHandler corsHandler = CorsHandler.create() .addRelativeOrigin(allowedOriginPattern) @@ -40,11 +53,19 @@ public void start(Promise startPromise) { corsHandler.allowCredentials(properties.getCorsAllowCredentials()); } - Route route = router.route().handler(corsHandler); + Route route = router.route().handler(corsHandler); HealthCheckHandler healthCheckHandler = HealthCheckHandler.createWithHealthChecks(healthChecks); router.get("/health").handler(healthCheckHandler); + // Add frontend configuration endpoint if service is available and enabled + if (oidcSecurityServiceProperties.isEnabled()) { + String configPath = oidcSecurityServiceProperties.getFrontendConfigurationPath(); + logger.info("Adding frontend configuration endpoint at: {}", configPath); + + router.get(configPath).handler(this::handleFrontendConfiguration); + } + if(properties.isEnableStaticFileServer()) { route.handler(StaticHandler.create("webroot2")); } @@ -60,9 +81,38 @@ public void start(Promise startPromise) { }); } + /** + * Handle requests for frontend configuration. + * Generates configuration dynamically and returns it as JSON. + */ + private void handleFrontendConfiguration(RoutingContext context) { + try { + // Convert to JSON + String jsonConfig = objectMapper.writeValueAsString(oidcSecurityServiceProperties); + + // Send response + HttpServerResponse response = context.response(); + response.putHeader("Content-Type", "application/json"); + response.putHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.putHeader("Pragma", "no-cache"); + response.putHeader("Expires", "0"); + response.end(jsonConfig); + + logger.debug("Served frontend configuration for request from: {}", context.request().remoteAddress()); + + } catch (Exception e) { + logger.error("Failed to generate frontend configuration", e); + + // Send error response + HttpServerResponse response = context.response(); + response.setStatusCode(500); + response.putHeader("Content-Type", "application/json"); + response.end("{\"error\": \"Failed to generate configuration\"}"); + } + } + @Override public void stop(Promise stopPromise) { server.close(stopPromise); } - } diff --git a/structures-core/src/test/java/org/kinotic/structures/DummySecurityService.java b/structures-core/src/test/java/org/kinotic/structures/DummySecurityService.java index 40f38c025..43a18be4c 100644 --- a/structures-core/src/test/java/org/kinotic/structures/DummySecurityService.java +++ b/structures-core/src/test/java/org/kinotic/structures/DummySecurityService.java @@ -22,6 +22,7 @@ import org.kinotic.continuum.api.security.Participant; import org.kinotic.continuum.api.security.ParticipantConstants; import org.kinotic.continuum.api.security.SecurityService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; @@ -36,6 +37,7 @@ * Created by Navid Mitchell on 3/11/20 */ @Component +@ConditionalOnProperty(prefix = "oidc-security-service", name = "enabled", havingValue = "false", matchIfMissing = false) public class DummySecurityService implements SecurityService { @Override diff --git a/structures-frontend-next/.env b/structures-frontend-next/.env deleted file mode 100644 index 9994f21ca..000000000 --- a/structures-frontend-next/.env +++ /dev/null @@ -1,5 +0,0 @@ -VUE_APP_KEYCLOAK_SUPPORT=false -VUE_APP_KEYCLOAK_CLIENT_ID=kc-client -VUE_APP_KEYCLOAK_REALM=kc-realm -VUE_APP_KEYCLOAK_ROLE=none -VUE_APP_KEYCLOAK_URL=http://127.0.0.1:8081/auth diff --git a/structures-frontend-next/CONFIGURATION.md b/structures-frontend-next/CONFIGURATION.md new file mode 100644 index 000000000..3ec12d3b5 --- /dev/null +++ b/structures-frontend-next/CONFIGURATION.md @@ -0,0 +1,196 @@ +# Runtime Configuration + +The `structures-frontend-next` application now supports runtime configuration loading from JSON files instead of environment variables. This allows you to change configuration without rebuilding the application. + +## Configuration File Locations + +The application will look for configuration files in the following order, with optional local overrides applied on top: + +1. Base: `/config/app-config.json` (preferred) + - Local override (optional): `/config/app-config.override.json` or `/config/app-config.json.local` +2. Base: `/app-config.json` (fallback) + - Local override (optional): `/app-config.override.json` or `/app-config.json.local` +3. If no base file exists, a local override (if present) will be merged onto defaults + +## Configuration File Format + +Create a JSON file with the following structure: + +```json +{ + "oidc": { + "okta": { + "enabled": false, + "client_id": "your-okta-client-id", + "authority": "https://your-okta-domain.okta.com", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + }, + "keycloak": { + "enabled": true, + "client_id": "your-keycloak-client-id", + "authority": "http://localhost:8080/realms/your-realm", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + }, + "google": { + "enabled": false, + "client_id": "your-google-client-id", + "authority": "https://accounts.google.com", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + }, + "github": { + "enabled": false, + "client_id": "your-github-client-id", + "authority": "https://github.com", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + }, + "microsoft": { + "enabled": false, + "client_id": "your-microsoft-client-id", + "authority": "https://login.microsoftonline.com/common/v2.0", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew", + "resource": "your-microsoft-resource" + }, + "microsoftSocial": { + "enabled": false, + "client_id": "your-microsoft-social-client-id", + "authority": "https://login.microsoftonline.com/consumers/v2.0", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + }, + "custom": { + "enabled": false, + "client_id": "your-custom-client-id", + "authority": "https://your-custom-authority.com", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew", + "metadata": { + "authorization_endpoint": "https://your-custom-authority.com/oauth/authorize", + "token_endpoint": "https://your-custom-authority.com/oauth/token", + "userinfo_endpoint": "https://your-custom-authority.com/oauth/userinfo", + "end_session_endpoint": "https://your-custom-authority.com/oauth/logout", + "jwks_uri": "https://your-custom-authority.com/.well-known/jwks.json" + } + }, + "apple": { + "enabled": false, + "client_id": "your-apple-client-id", + "authority": "https://appleid.apple.com", + "redirect_uri": "http://localhost:5173/login", + "post_logout_redirect_uri": "http://localhost:5173", + "silent_redirect_uri": "http://localhost:5173/login/silent-renew" + } + }, + "basicAuth": { + "enabled": true + }, + "debug": false +} +``` + +## Configuration Options + +### OIDC Providers + +Each OIDC provider has the following configuration options: + +- `enabled`: Boolean to enable/disable the provider +- `client_id`: The OAuth client ID from your identity provider +- `authority`: The authority URL for your identity provider +- `redirect_uri`: The redirect URI after successful authentication +- `post_logout_redirect_uri`: The redirect URI after logout +- `silent_redirect_uri`: The redirect URI for silent token renewal + +### Microsoft-specific Options + +For Microsoft providers, you can also specify: +- `resource`: Custom resource identifier for Microsoft v2.0 endpoints + +### Custom Provider + +For custom OIDC providers, you can specify: +- `metadata`: Complete OIDC metadata including endpoints + +### Basic Authentication + +- `enabled`: Boolean to enable/disable basic username/password authentication + +### Debug Mode + +- `debug`: Boolean to enable debug logging + +## Setup Instructions + +1. Copy the example configuration file: + ```bash + cp public/app-config.json.example public/app-config.json + ``` + +2. Edit the configuration file with your settings: + ```bash + # Edit the configuration file + nano public/app-config.json + ``` + +3. Build the application: + ```bash + npm run build + ``` + +4. Deploy the application with your configuration file in the web root. + +## Development + +During development, place files in `public/` and they will be served by Vite. You can optionally create a local-only override that should not be committed: + +```bash +# Example: local override next to base file +cp public/app-config.json public/app-config.override.json + +# Or use the .json.local suffix +cp public/app-config.json public/app-config.json.local +``` + +Git ignore example (add to your ignore rules): +``` +public/app-config.override.json +public/app-config.json.local +config/app-config.override.json +config/app-config.json.local +``` + +## Production Deployment + +For production deployment: + +1. Place your configuration file in the web root of your server +2. Ensure the file is accessible at `/app-config.json` or `/config/app-config.json` +3. The application will load the configuration at runtime + +## Fallback Behavior + +If no configuration file is found, the application will use default settings: +- Keycloak enabled with default settings +- Basic authentication enabled +- Debug mode disabled + +## Migration from Environment Variables + +If you were previously using environment variables, you can migrate by: + +1. Creating a configuration file with your current settings +2. Removing the environment variables from your build process +3. Deploying the configuration file with your application + +The application will automatically detect and use the new configuration system. diff --git a/structures-frontend-next/README.md b/structures-frontend-next/README.md index 33895ab20..dbfdc1618 100644 --- a/structures-frontend-next/README.md +++ b/structures-frontend-next/README.md @@ -1,5 +1,259 @@ -# Vue 3 + TypeScript + Vite +# Structures Frontend Next -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` \ No newline at end of file diff --git a/structures-frontend-next/src/pages/login/Login.vue b/structures-frontend-next/src/pages/login/Login.vue new file mode 100644 index 000000000..a0775f41e --- /dev/null +++ b/structures-frontend-next/src/pages/login/Login.vue @@ -0,0 +1,614 @@ + + + \ No newline at end of file diff --git a/structures-frontend-next/src/pages/login/OidcConfiguration.ts b/structures-frontend-next/src/pages/login/OidcConfiguration.ts new file mode 100644 index 000000000..356375e7b --- /dev/null +++ b/structures-frontend-next/src/pages/login/OidcConfiguration.ts @@ -0,0 +1,167 @@ +import { type UserManagerSettings, WebStorageStateStore, UserManager } from 'oidc-client-ts'; +import { configService } from '@/util/config'; + +export interface OidcProviderConfig extends Partial { + enabled: boolean; + clientId: string; + clientSecret?: string; + authority: string; + redirectUri: string; + postLogoutRedirectUri?: string; + silentRedirectUri?: string; + loadUserInfo?: boolean; + additionalScopes?: string; + // Additional OIDC parameters for better UX + enableEmailPreFill?: boolean; + enableDomainHint?: boolean; + metadata?: any; + publicClient?: PublicClientConfig; +} + +export interface PublicClientConfig { + isPublicClient: boolean; + responseType: string; + responseMode: string; +} + +export interface OidcConfiguration { + defaultProvider: string; + providers: Record; + defaultSettings: Partial; +} + +const DEFAULT_SETTINGS: Partial = { + response_type: 'code', + response_mode: 'query', + scope: 'openid profile email offline_acces', + loadUserInfo: true, + monitorSession: true, + userStore: new WebStorageStateStore({ store: window.localStorage }), + stateStore: new WebStorageStateStore({ store: window.localStorage }), +}; + +// Create a function to get the OIDC configuration dynamically +export async function getOidcConfiguration(): Promise { + const oidcConfig = await configService.loadConfig(); + const isDebug = await configService.isDebugEnabled(); + + // Debug: Log configuration for troubleshooting + if (isDebug) { + console.log('OIDC Configuration:', oidcConfig); + } + + // Get all enabled OIDC providers + const enabledProviders = await configService.getEnabledOidcProviders(); + + // Build dynamic providers configuration + const providers: Record = {}; + + for (const provider of enabledProviders) { + providers[provider.provider] = { + enabled: provider.enabled, + clientId: provider.clientId, + clientSecret: '', + authority: provider.authority, + redirectUri: provider.redirectUri, + postLogoutRedirectUri: provider.postLogoutRedirectUri, + silentRedirectUri: provider.silentRedirectUri, + loadUserInfo: true, + additionalScopes: provider.additionalScopes ?? '', + publicClient: { + isPublicClient: true, + responseType: 'code', + responseMode: 'query' + }, + // Add custom metadata if available + ...(provider.metadata && { + metadata: { + authorizationEndpoint: provider.metadata.authorization_endpoint, + tokenEndpoint: provider.metadata.token_endpoint, + userinfoEndpoint: provider.metadata.userinfo_endpoint, + endSessionEndpoint: provider.metadata.end_session_endpoint, + jwksUri: provider.metadata.jwks_uri, + } + }) + }; + } + + // Determine default provider (use first enabled provider, or 'keycloak' as fallback) + const defaultProvider = enabledProviders.length > 0 ? enabledProviders[0].provider : 'keycloak'; + + return { + defaultProvider, + defaultSettings: DEFAULT_SETTINGS, + providers + }; +} + +export async function getProviderConfig(providerName: string): Promise { + const config = await getOidcConfiguration(); + return config.providers[providerName] || null; +} + +export async function createUserManagerSettings(providerName: string): Promise { + const config = await getOidcConfiguration(); + const providerConfig = config.providers[providerName]; + + if (!providerConfig) { + throw new Error(`Provider configuration not found for: ${providerName}`); + } + + if (!providerConfig.enabled) { + throw new Error(`Provider ${providerName} is not enabled`); + } + + // Merge default settings with provider-specific settings + const settings: UserManagerSettings = { + ...config.defaultSettings, + authority: providerConfig.authority, + client_id: providerConfig.clientId, + redirect_uri: providerConfig.redirectUri, + post_logout_redirect_uri: providerConfig.postLogoutRedirectUri, + silent_redirect_uri: providerConfig.silentRedirectUri, + loadUserInfo: providerConfig.loadUserInfo ?? true, + // Ensure required fields are set + response_type: 'code', + response_mode: 'query', + scope: `openid profile email offline_access ${providerConfig.additionalScopes ?? ''}`, + // Add custom metadata if available + ...(providerConfig.metadata && { + metadata: { + authorization_endpoint: providerConfig.metadata.authorizationEndpoint, + token_endpoint: providerConfig.metadata.tokenEndpoint, + userinfo_endpoint: providerConfig.metadata.userinfoEndpoint, + end_session_endpoint: providerConfig.metadata.endSessionEndpoint, + jwks_uri: providerConfig.metadata.jwksUri, + } + }) + }; + + // Log settings for debugging + console.log('UserManager settings created:', { + authority: settings.authority, + client_id: settings.client_id, + redirect_uri: settings.redirect_uri, + enableEmailPreFill: providerConfig.enableEmailPreFill, + enableDomainHint: providerConfig.enableDomainHint + }); + + return settings; +} + +export async function createUserManager(providerName: string): Promise { + const settings = await createUserManagerSettings(providerName); + return new UserManager(settings); +} + +// Helper function to get all available provider names +export async function getAvailableProviders(): Promise { + const enabledProviders = await configService.getEnabledOidcProviders(); + return enabledProviders.map(provider => provider.provider); +} + +// Helper function to check if a specific provider is enabled +export async function isProviderEnabled(providerName: string): Promise { + const provider = await configService.getOidcProviderByName(providerName); + return provider?.enabled ?? false; +} \ No newline at end of file diff --git a/structures-frontend-next/src/pages/routes.ts b/structures-frontend-next/src/pages/routes.ts index a4cfc5d55..589983120 100644 --- a/structures-frontend-next/src/pages/routes.ts +++ b/structures-frontend-next/src/pages/routes.ts @@ -160,7 +160,7 @@ const pageRoutes: RouteRecordRaw[] = [ }, { path: '/login', - component: () => import('@/pages/Login.vue'), + component: () => import('@/pages/login/Login.vue'), meta: { showInMainNav: false, authenticationRequired: false diff --git a/structures-frontend-next/src/states/IUserState.ts b/structures-frontend-next/src/states/IUserState.ts index 6f210f828..edef6ad43 100644 --- a/structures-frontend-next/src/states/IUserState.ts +++ b/structures-frontend-next/src/states/IUserState.ts @@ -1,21 +1,22 @@ import { ConnectedInfo, ConnectionInfo, Continuum } from '@kinotic/continuum-client' import { reactive } from 'vue' import Cookies from 'js-cookie' +import { User } from 'oidc-client-ts' export interface IUserState { - connectedInfo: ConnectedInfo | null + oidcUser: User | null isAccessDenied(): boolean - isAuthenticated(): boolean - authenticate(login: string, passcode: string): Promise + handleOidcLogin(user: User): Promise + logout(): Promise } export class UserState implements IUserState { - public connectedInfo: ConnectedInfo | null = null + public oidcUser: User | null = null private authenticated: boolean = false private accessDenied: boolean = false @@ -25,6 +26,7 @@ export class UserState implements IUserState { login, passcode } + const btoaToken = btoa(`${login}:${passcode}`) try { @@ -47,12 +49,88 @@ export class UserState implements IUserState { } } + public async handleOidcLogin(user: User): Promise { + const connectionInfo: ConnectionInfo = this.createConnectionInfo() + + // Determine which token to use + let tokenToUse = user.access_token; + + // For Microsoft social login, check if access_token is a valid JWT + // If not, use the ID token instead + if (user.access_token && !this.isValidJWT(user.access_token)) { + console.log('Access token is not a valid JWT, using ID token for Microsoft social login'); + tokenToUse = user.id_token || user.access_token; + } + + connectionInfo.connectHeaders = { + Authorization: `Bearer ${tokenToUse}` + } + + try { + this.connectedInfo = await Continuum.connect(connectionInfo) + this.authenticated = true + this.accessDenied = false + this.oidcUser = user + + // Store the token in a cookie + Cookies.set('token', tokenToUse, { + sameSite: 'strict', + secure: true, + expires: new Date(user.expires_at! * 1000) // Convert Unix timestamp to Date + }) + + // Store refresh token if available + if (user.refresh_token) { + Cookies.set('oidc_refresh_token', user.refresh_token, { + sameSite: 'strict', + secure: true, + expires: 30 // 30 days for refresh token + }) + } + } catch (reason: any) { + this.accessDenied = true + if (reason) { + throw new Error(reason) + } else { + throw new Error('OIDC authentication failed') + } + } + } + + public async logout(): Promise { + // Clear all auth-related cookies + Cookies.remove('token') + Cookies.remove('oidc_refresh_token') + + // Reset state + this.connectedInfo = null + this.oidcUser = null + this.authenticated = false + this.accessDenied = false + + // Disconnect from Continuum if connected + if (this.connectedInfo) { + try { + await Continuum.disconnect() + } catch (error) { + console.error('Error disconnecting from Continuum:', error) + } + } + } + public isAccessDenied(): boolean { return this.accessDenied } public isAuthenticated(): boolean { - return this.authenticated + return this.authenticated && ( + // Either we have a basic auth token + Cookies.get('token') !== undefined || + // Or we have a valid OIDC token + (this.oidcUser !== null && + this.oidcUser.expires_at !== undefined && + this.oidcUser.expires_at * 1000 > Date.now()) + ) } public createConnectionInfo(): ConnectionInfo { @@ -75,6 +153,27 @@ export class UserState implements IUserState { return connectionInfo } + /** + * Check if a token is a valid JWT format + */ + private isValidJWT(token: string): boolean { + try { + // Check if token has the JWT format (3 parts separated by dots) + const parts = token.split('.'); + if (parts.length !== 3) { + return false; + } + + // Try to decode the header and payload + const header = JSON.parse(atob(parts[0])); + const payload = JSON.parse(atob(parts[1])); + + // Check for required JWT claims + return !!(header.alg && payload.iss && payload.aud); + } catch (error) { + return false; + } + } } export const USER_STATE: IUserState = reactive(new UserState()) diff --git a/structures-frontend-next/src/util/config.ts b/structures-frontend-next/src/util/config.ts new file mode 100644 index 000000000..57b96d2b9 --- /dev/null +++ b/structures-frontend-next/src/util/config.ts @@ -0,0 +1,201 @@ +import { ConnectionInfo } from '@kinotic/continuum-client' + +interface OidcProvider { + enabled: boolean; + provider: string; + displayName: string; + clientId: string; + authority: string; + redirectUri: string; + postLogoutRedirectUri: string; + silentRedirectUri: string; + audience?: string; + domains?: string[]; + roles?: string[]; + rolesClaimPath?: string; + additionalScopes?: string; + metadata?: Record; +} + +interface AppConfig { + // OIDC Configuration + enabled: boolean; + tenantIdFieldName: string; + oidcProviders: OidcProvider[]; + // Debug + debug: boolean; + frontendConfigurationPath: string; +} + +class ConfigService { + + private config: AppConfig | null = null; + private configPromise: Promise | null = null; + + async loadConfig(): Promise { + if (this.config) { + return this.config; + } + + if (this.configPromise) { + return this.configPromise; + } + + this.configPromise = this.loadConfigFromFile(); + this.config = await this.configPromise; + return this.config; + } + + private async loadConfigFromFile(): Promise { + // Load base configuration locally + const baseConfig = await this.loadLocalConfig(); + if (!baseConfig) { + console.warn('No base configuration file found, using default configuration'); + return this.getDefaultConfig(); + } + + // Load override configuration if available (this would be from the backend) + const overrideConfig = await this.loadOverrideConfig(); + if (overrideConfig) { + console.log('Applying configuration override from backend'); + return this.deepMerge(baseConfig, overrideConfig); + } + + console.log('Loaded configuration from local app-config.json'); + return baseConfig; + } + + private async loadLocalConfig(): Promise { + try { + // Load the local config file from the public folder + const resp = await fetch('/app-config.json'); + if (resp.ok) { + return await resp.json(); + } + } catch (error) { + console.warn('Failed to load local app-config.json:', error); + } + return null; + } + + private async loadOverrideConfig(): Promise | null> { + try { + // This would fetch from the backend's oidc-security-service configuration + // TODO: This is a hack to get the override config from the backend + // TODO: We should use the Continuum client to get the config + const connectionInfo = this.createConnectionInfo(); + const resp = await fetch(`${connectionInfo.useSSL ? 'https' : 'http'}://${connectionInfo.host}:${connectionInfo.port}/app-config.override.json`); + if (resp.ok) { + return await resp.json(); + } + } catch (error) { + // Silently ignore if override config is not available + } + return null; + } + + private createConnectionInfo(): ConnectionInfo { + const connectionInfo: ConnectionInfo = { + host: '127.0.0.1', + port: 9091 + } + if (window.location.hostname !== '127.0.0.1' + && window.location.hostname !== 'localhost') { + if (window.location.protocol.startsWith('https')) { + connectionInfo.useSSL = true + } + if (window.location.port !== '') { + connectionInfo.port = 9091 + } else { + connectionInfo.port = null + } + connectionInfo.host = window.location.hostname + } + return connectionInfo +} + + + private deepMerge(target: T, source: Partial): T { + const result = { ...target }; + + for (const key in source) { + if (source[key] !== undefined) { + if (typeof source[key] === 'object' && source[key] !== null && + typeof result[key] === 'object' && result[key] !== null) { + result[key] = this.deepMerge(result[key], source[key] as any); + } else { + result[key] = source[key] as any; + } + } + } + + return result; + } + + private getDefaultConfig(): AppConfig { + return { + enabled: false, + tenantIdFieldName: 'tenantId', + debug: false, + oidcProviders: [], + frontendConfigurationPath: '/app-config.override.json' + }; + } + + // Helper methods to get specific config values + + async getEnabledOidcProviders() { + const config = await this.loadConfig(); + return config?.oidcProviders.filter(provider => provider.enabled) || []; + } + + async getOidcProviderByName(providerName: string): Promise { + const config = await this.loadConfig(); + const providers = config?.oidcProviders || []; + return providers.find(provider => provider.provider === providerName); + } + + async isOidcEnabled(): Promise { + const config = await this.loadConfig(); + return config?.enabled || false; + } + + async isBasicAuthEnabled(): Promise { + const config = await this.loadConfig(); + return !config?.enabled || (config?.enabled && config?.oidcProviders.length === 0); + } + + async isDebugEnabled(): Promise { + const config = await this.loadConfig(); + return config?.debug; + } + + async getTenantIdFieldName(): Promise { + const config = await this.loadConfig(); + return config?.tenantIdFieldName || 'tenantId'; + } + + + // New method to find provider by email domain + async findProviderByEmailDomain(email: string): Promise { + const domain = this.extractDomainFromEmail(email); + if (!domain) return null; + + const config = await this.loadConfig(); + return config?.oidcProviders.find(provider => + provider.domains && provider.domains.includes(domain) + ) || null; + } + + private extractDomainFromEmail(email: string): string | null { + const atIndex = email.indexOf('@'); + if (atIndex === -1) return null; + return email.substring(atIndex + 1).toLowerCase(); + } +} + +// Export singleton instance +export const configService = new ConfigService(); + +// Export types for use in other files +export type { AppConfig, OidcProvider }; diff --git a/structures-frontend-next/tsconfig.app.json b/structures-frontend-next/tsconfig.app.json index 7c2ee22a9..31aac7798 100644 --- a/structures-frontend-next/tsconfig.app.json +++ b/structures-frontend-next/tsconfig.app.json @@ -11,6 +11,7 @@ "noUncheckedSideEffectImports": true, "experimentalDecorators": true, "module": "ESNext", + "types": ["node"], "paths": { "@/*": [ "./src/*" @@ -18,5 +19,5 @@ } }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "public/app-config.override.json"] } diff --git a/structures-frontend/src/frontends/develop/services/ITestService.ts b/structures-frontend/src/frontends/develop/services/ITestService.ts index 4e4375dbe..57f67f890 100644 --- a/structures-frontend/src/frontends/develop/services/ITestService.ts +++ b/structures-frontend/src/frontends/develop/services/ITestService.ts @@ -30,7 +30,7 @@ export class TestService implements ITestService { } public testUser(): Promise { - return this.serviceProxy.invoke('testUser') + return this.serviceProxy.invoke('testuser@example.com') } public testFlux(): Observable { diff --git a/structures-js/cursorrules b/structures-js/cursorrules new file mode 100644 index 000000000..b6fde3422 --- /dev/null +++ b/structures-js/cursorrules @@ -0,0 +1,49 @@ +# Structures JavaScript Modules + +## Overview +Collection of TypeScript/JavaScript modules including API client, CLI, E2E testing, and load generation. + +## Module Structure +- **structures-api**: TypeScript API client library +- **structures-cli**: Command-line interface tools +- **structures-e2e**: End-to-end testing framework +- **load-generator**: Performance testing and load generation + +## Development Patterns +1. **TypeScript**: Strict typing for all modules +2. **ES Modules**: Use ES module syntax +3. **Testing**: Comprehensive unit and integration tests +4. **Documentation**: JSDoc comments for all public APIs +5. **Error Handling**: Proper error handling with custom error types + +## Build System +- **Package Manager**: pnpm for dependency management +- **Bundling**: Vite for development, esbuild for production +- **TypeScript**: Strict configuration with path mapping +- **Testing**: Vitest for unit tests, Playwright for E2E + +## Key Patterns +- **API Client**: Follow patterns in structures-api/ +- **CLI Tools**: Use Commander.js patterns in structures-cli/ +- **E2E Testing**: Use Playwright patterns in structures-e2e/ +- **Load Testing**: Use k6 patterns in load-generator/ + +## Dependencies +- TypeScript 5.x +- pnpm for package management +- Vitest for testing +- Playwright for E2E testing +- k6 for load testing +- Commander.js for CLI tools + +## Testing Strategy +- Unit tests for all modules +- Integration tests for API client +- E2E tests for user workflows +- Performance tests with load generators + +## Configuration +- TypeScript configuration per module +- Environment-specific settings +- API endpoint configuration +- Test environment setup \ No newline at end of file diff --git a/structures-js/structures-api/gradle/wrapper/gradle-wrapper.jar b/structures-js/structures-api/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e6441136f..000000000 Binary files a/structures-js/structures-api/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/structures-js/structures-api/gradle/wrapper/gradle-wrapper.properties b/structures-js/structures-api/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index a4413138c..000000000 --- a/structures-js/structures-api/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/structures-js/structures-api/gradlew b/structures-js/structures-api/gradlew deleted file mode 100755 index b740cf133..000000000 --- a/structures-js/structures-api/gradlew +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/structures-js/structures-api/gradlew.bat b/structures-js/structures-api/gradlew.bat deleted file mode 100644 index 7101f8e46..000000000 --- a/structures-js/structures-api/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/structures-js/structures-e2e/gradle/wrapper/gradle-wrapper.jar b/structures-js/structures-e2e/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e6441136f..000000000 Binary files a/structures-js/structures-e2e/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/structures-js/structures-e2e/gradle/wrapper/gradle-wrapper.properties b/structures-js/structures-e2e/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index a4413138c..000000000 --- a/structures-js/structures-e2e/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/structures-js/structures-e2e/gradlew b/structures-js/structures-e2e/gradlew deleted file mode 100755 index b740cf133..000000000 --- a/structures-js/structures-e2e/gradlew +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/structures-js/structures-e2e/gradlew.bat b/structures-js/structures-e2e/gradlew.bat deleted file mode 100644 index 7101f8e46..000000000 --- a/structures-js/structures-e2e/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/structures-server/README.md b/structures-server/README.md new file mode 100644 index 000000000..92b1bf4ee --- /dev/null +++ b/structures-server/README.md @@ -0,0 +1,206 @@ +# Structures Server + +A Spring Boot server application that provides REST API, GraphQL, and web interface access to the Structures framework. + +## Overview + +Structures Server is a complete server application that exposes the Structures Core library through: +- **REST API**: Comprehensive REST endpoints for all data operations +- **GraphQL API**: Full GraphQL interface with schema introspection +- **Web Interface**: Built-in web GUI for data management +- **Health Checks**: Application health monitoring and metrics + +## Features + +- **REST API**: Complete CRUD operations via REST endpoints +- **GraphQL API**: Native GraphQL support with playground +- **Web GUI**: Built-in web interface for data management +- **Multi-tenant Support**: Tenant isolation and management +- **Authentication**: OIDC, JWT, and basic authentication support +- **CORS Support**: Configurable cross-origin resource sharing +- **Health Monitoring**: Application health and readiness checks +- **Static File Serving**: Web asset hosting capabilities + +## Quick Start + +### 1. Build the application + +```bash +./gradlew :structures-server:build +``` + +### 2. Run the server + +```bash +./gradlew :structures-server:bootRun +``` + +### 3. Access the services + +- **Web Interface**: http://localhost:9090 +- **GraphQL Playground**: http://localhost:4000/graphql +- **REST API**: http://localhost:8080/api/ +- **Health Check**: http://localhost:9090/health/ + +## Configuration + +### Server Configuration +```yaml +structures: + web-server: + port: 9090 + enable-static-file-server: true + health-check-path: /health/ + + open-api: + port: 8080 + path: /api/ + server-url: http://127.0.0.1:8080 + security-type: BASIC + + graphql: + port: 4000 + path: /graphql/ + + cors: + allowed-origin-pattern: "*" +``` + +### Data Source Configuration +```yaml +structures: + elasticsearch: + connections: + - scheme: http + host: elasticsearch + port: 9200 + username: elastic + password: changeme + connection-timeout: 5s + socket-timeout: 60s + index-prefix: struct_ + tenant-id-field: structuresTenantId +``` + +### Authentication Configuration +```yaml +structures: + auth: + oidc: + enabled: true + allowed-issuers: + - "https://sts.windows.net" + - "https://your-okta-domain.okta.com" + basic-auth: + enabled: true +``` + +## API Endpoints + +### REST API (`/api/`) +- `GET /api/structures/{tenantId}/{schemaId}` - List structures +- `POST /api/structures/{tenantId}/{schemaId}` - Create structure +- `GET /api/structures/{tenantId}/{schemaId}/{id}` - Get structure +- `PUT /api/structures/{tenantId}/{schemaId}/{id}` - Update structure +- `DELETE /api/structures/{tenantId}/{schemaId}/{id}` - Delete structure +- `GET /api/schemas/{tenantId}` - List schemas +- `POST /api/schemas/{tenantId}` - Create schema + +### GraphQL API (`/graphql/`) +- **Query**: `structures`, `schemas`, `searchStructures` +- **Mutation**: `createStructure`, `updateStructure`, `deleteStructure` +- **Subscription**: Real-time updates (if configured) + +### Health Endpoints +- `GET /health/` - Application health status +- `GET /health/readiness` - Readiness probe +- `GET /health/liveness` - Liveness probe + +## Development + +### Running in Development Mode +```bash +# Start with hot reload +./gradlew :structures-server:bootRun --args='--spring.profiles.active=dev' + +# Run with specific configuration +./gradlew :structures-server:bootRun --args='--server.port=9091' +``` + +### Testing +```bash +# Run all tests +./gradlew :structures-server:test + +# Run specific test class +./gradlew :structures-server:test --tests *StructureControllerTest +``` + +## Docker Deployment + +### Build Docker Image +```bash +./gradlew :structures-server:dockerBuild +``` + +### Run with Docker Compose +```bash +cd docker-compose +docker-compose up -d +``` + +## Production Considerations + +### Security +- Enable HTTPS in production +- Configure proper CORS policies +- Use secure authentication methods +- Implement rate limiting + +### Performance +- Configure connection pooling +- Enable response caching +- Monitor Elasticsearch performance +- Use load balancing for high availability + +### Monitoring +- Enable metrics collection +- Configure logging aggregation +- Set up health check monitoring +- Monitor resource usage + +## Troubleshooting + +### Common Issues + +1. **Port Conflicts** + - Check if ports 8080, 4000, or 9090 are already in use + - Configure different ports in application.yml + +2. **Elasticsearch Connection** + - Verify Elasticsearch is running and accessible + - Check connection credentials and network access + +3. **Authentication Issues** + - Verify OIDC provider configuration + - Check JWT token validity and claims + +### Debug Mode +Enable debug logging for troubleshooting: + +```yaml +logging: + level: + org.kinotic.structures: DEBUG + org.springframework.security: DEBUG +``` + +## Related Documentation + +- [Structures Core](../structures-core/README.md) - Core library documentation +- [Structures Auth](../structures-auth/README.md) - Authentication documentation +- [Getting Started Guide](../../webdocs/guide/getting-started.md) - Complete setup guide + +## License + +This application is part of the Structures framework and follows the same licensing terms. diff --git a/structures-server/build.gradle b/structures-server/build.gradle index e2fae00a0..a65ac835b 100644 --- a/structures-server/build.gradle +++ b/structures-server/build.gradle @@ -12,24 +12,41 @@ sourceSets { dependencies { implementation project(':structures-core') + implementation project(':structures-auth') // Additional Continuum Dependencies + implementation "org.kinotic:continuum-core" implementation "org.kinotic:continuum-gateway" implementation 'com.github.ben-manes.caffeine:caffeine' + + implementation 'io.jsonwebtoken:jjwt-api' + implementation 'io.jsonwebtoken:jjwt-impl' + implementation 'io.jsonwebtoken:jjwt-jackson' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' - testImplementation "org.testcontainers:testcontainers:${testContainersVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'com.fasterxml.jackson.core:jackson-databind' + testImplementation "org.testcontainers:testcontainers:${testContainersVersion}" + testImplementation "org.testcontainers:junit-jupiter:${testContainersVersion}" testImplementation "org.testcontainers:elasticsearch:${testContainersVersion}" + testImplementation 'com.github.dasniko:testcontainers-keycloak:3.8.0' testImplementation 'junit:junit:4.13.1' } test { - useJUnitPlatform() + + environment 'JAVA_TOOL_OPTIONS', '--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED --add-opens=jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED --add-opens=java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.lang.in=ALL-UNNAMED' + systemProperty 'spring.profiles.active', 'test' + } + tasks.named('processResources') { dependsOn ":structures-frontend-next:copyDist" } + +tasks.named('sourcesJar') { + dependsOn ":structures-frontend-next:copyDist" +} diff --git a/structures-server/src/main/java/org/kinotic/structuresserver/config/TemporarySecurityService.java b/structures-server/src/main/java/org/kinotic/structuresserver/config/TemporarySecurityService.java index f5810a43b..55c141469 100644 --- a/structures-server/src/main/java/org/kinotic/structuresserver/config/TemporarySecurityService.java +++ b/structures-server/src/main/java/org/kinotic/structuresserver/config/TemporarySecurityService.java @@ -7,6 +7,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import javax.security.sasl.AuthenticationException; import java.nio.charset.StandardCharsets; @@ -17,6 +19,7 @@ import java.util.concurrent.CompletableFuture; @Component +@ConditionalOnProperty(prefix = "oidc-security-service", name = "enabled", havingValue = "false", matchIfMissing = true) public class TemporarySecurityService implements SecurityService { @@ -45,6 +48,10 @@ public class TemporarySecurityService implements SecurityService { */ @Override public CompletableFuture authenticate(Map authenticationInfo) { + // we need to bring in a JWT library and verify the token + // however we should look at supporting audience and issuer validations + // but to do this we need to store those values with the organization + // we might need to know if a request for auth is coming from an application or the management UI. if(authenticationInfo.containsKey("login") && Objects.equals(authenticationInfo.get("login"), "admin") && authenticationInfo.containsKey("passcode") && Objects.equals(authenticationInfo.get("passcode"), PASSWORD)){ log.debug("Successfully authenticated user with continuum credentials"); diff --git a/structures-server/src/main/resources/application-development.yml b/structures-server/src/main/resources/application-development.yml index 632981b1d..19e1c09e4 100644 --- a/structures-server/src/main/resources/application-development.yml +++ b/structures-server/src/main/resources/application-development.yml @@ -15,6 +15,14 @@ logging: util: DEBUG buffer: DEBUG +spring: + ai: + openai: + api-key: "-" + base-url: https://api.x.ai + chat: + options: + model: grok-4 structures: initializeWithSampleData: true @@ -22,12 +30,13 @@ structures: corsAllowedHeaders: - "*" corsAllowCredentials: true -# elastic-connections: -# - scheme: "https" -# host: "test.com" -# port: "443" -# - scheme: "http" -# host: "host2.com" -# port: "9200" -# elastic-password: "test" -# elastic-username: "bob" + elastic-connections: + - scheme: "http" + host: "localhost" + port: "9200" + + +# OIDC security service configuration +# oidc-security-service: +# enabled: false +# debug: false diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/ElasticTestBase.java b/structures-server/src/test/java/org/kinotic/structuresserver/ElasticTestBase.java new file mode 100644 index 000000000..2cbce61e3 --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/ElasticTestBase.java @@ -0,0 +1,16 @@ +package org.kinotic.structuresserver; + + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.kinotic.structures.api.annotations.EnableStructures; +import org.kinotic.structuresserver.config.ElasticsearchTestContextInitializer; + +@ActiveProfiles("test") +@ContextConfiguration(initializers = ElasticsearchTestContextInitializer.class) +@SpringBootTest +@EnableStructures +public abstract class ElasticTestBase { + +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/StructuresServerTestApplication.java b/structures-server/src/test/java/org/kinotic/structuresserver/StructuresServerTestApplication.java index 65c494b27..64b8e582d 100644 --- a/structures-server/src/test/java/org/kinotic/structuresserver/StructuresServerTestApplication.java +++ b/structures-server/src/test/java/org/kinotic/structuresserver/StructuresServerTestApplication.java @@ -1,12 +1,22 @@ package org.kinotic.structuresserver; -import org.kinotic.structuresserver.tests.TestBase; -import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.kinotic.structuresserver.config.ElasticsearchTestContextInitializer; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.elasticsearch.ReactiveElasticsearchClientAutoConfiguration; import org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration; -import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = ElasticsearchTestContextInitializer.class) +@EnableAutoConfiguration(exclude = {HazelcastAutoConfiguration.class, + JpaRepositoriesAutoConfiguration.class, + ReactiveElasticsearchClientAutoConfiguration.class}) +public class StructuresServerTestApplication { + public static void main(String[] args) { + SpringApplication.run(StructuresServerTestApplication.class, args); + } -@SpringBootApplication(exclude = {HazelcastAutoConfiguration.class, JpaRepositoriesAutoConfiguration.class}) -@EnableConfigurationProperties -public class StructuresServerTestApplication extends TestBase { } diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/config/ContainerHealthChecker.java b/structures-server/src/test/java/org/kinotic/structuresserver/config/ContainerHealthChecker.java new file mode 100644 index 000000000..67ded61a8 --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/config/ContainerHealthChecker.java @@ -0,0 +1,127 @@ +package org.kinotic.structuresserver.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.URI; +import java.time.Duration; + +/** + * Utility class for checking container health status + */ +public class ContainerHealthChecker { + + private static final Logger log = LoggerFactory.getLogger(ContainerHealthChecker.class); + + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + /** + * Check if Elasticsearch is healthy and ready + */ + public static boolean isElasticsearchHealthy(String host, int port) { + String healthUrl = String.format("http://%s:%d/_cluster/health", host, port); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(healthUrl)) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, + HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + String body = response.body(); + // Check if cluster is in a healthy state (green or yellow) + return body.contains("\"status\":\"green\"") || + body.contains("\"status\":\"yellow\""); + } + + log.debug("Elasticsearch health check returned status: {}", response.statusCode()); + return false; + + } catch (Exception e) { + log.debug("Elasticsearch health check failed: {}", e.getMessage()); + return false; + } + } + + /** + * Check if Keycloak is healthy and ready + */ +public static boolean isKeycloakHealthy(String host, int port) { + String healthUrl = String.format("http://%s:%d/health/ready", host, port); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(healthUrl)) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, + HttpResponse.BodyHandlers.ofString()); + + boolean isHealthy = response.statusCode() == 200; + if (isHealthy) { + log.debug("Keycloak health check successful"); + } else { + log.debug("Keycloak health check returned status: {}", response.statusCode()); + } + + return isHealthy; + + } catch (Exception e) { + log.debug("Keycloak health check failed: {}", e.getMessage()); + return false; + } + } + + /** + * Wait for a container to become healthy with retry logic + */ + public static boolean waitForContainerHealth( + String containerName, + HealthCheckFunction healthCheck, + int maxAttempts, + long delayMs) { + + log.info("Waiting for {} to become healthy...", containerName); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + if (healthCheck.check()) { + log.info("{} is healthy after {} attempts", containerName, attempt); + return true; + } + + if (attempt < maxAttempts) { + log.debug("{} health check attempt {} failed, retrying in {} ms...", + containerName, attempt, delayMs); + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Interrupted while waiting for {} to become healthy", containerName); + return false; + } + } + } + + log.error("{} failed to become healthy after {} attempts", containerName, maxAttempts); + return false; + } + + /** + * Functional interface for health checks + */ + @FunctionalInterface + public interface HealthCheckFunction { + boolean check(); + } +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestConfiguration.java b/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestConfiguration.java new file mode 100644 index 000000000..3acac8d7a --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestConfiguration.java @@ -0,0 +1,255 @@ +package org.kinotic.structuresserver.config; + +import java.time.Duration; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.elasticsearch.ElasticsearchContainer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Component +@Profile("test") +public class ElasticsearchTestConfiguration { + private static final Logger log = LoggerFactory.getLogger(ElasticsearchTestConfiguration.class); + + public static final ElasticsearchContainer ELASTICSEARCH_CONTAINER; + + // Flag to track if containers are fully ready + private static volatile boolean containersReady = false; + private static final Object containerLock = new Object(); + + static { + log.info("Starting TestContainers..."); + + // Start Elasticsearch container with proper wait strategy + String osName = System.getProperty("os.name"); + String osArch = System.getProperty("os.arch"); + + ELASTICSEARCH_CONTAINER = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.18.1"); + ELASTICSEARCH_CONTAINER.withEnv("discovery.type", "single-node") + .withEnv("xpack.security.enabled", "false"); + + // We need this until this is resolved https://github.com/elastic/elasticsearch/issues/118583 + if(osName != null && osName.startsWith("Mac") && osArch != null && osArch.equals("aarch64")){ + ELASTICSEARCH_CONTAINER.withEnv("_JAVA_OPTIONS", "-XX:UseSVE=0"); + } + + ELASTICSEARCH_CONTAINER.waitingFor( + Wait.forHttp("/_cluster/health") + .forPort(9200) + .withStartupTimeout(Duration.ofMinutes(3)) + ); + + // Start containers synchronously to ensure they're ready before class loading completes + startContainersSynchronously(); + + // Add shutdown hook to ensure containers are cleaned up + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + log.info("Shutting down TestContainers..."); + try { + if (ELASTICSEARCH_CONTAINER != null && ELASTICSEARCH_CONTAINER.isRunning()) { + ELASTICSEARCH_CONTAINER.stop(); + log.info("Elasticsearch container stopped"); + } + } catch (Exception e) { + log.warn("Error during container shutdown", e); + } + })); + } + + /** + * Start containers synchronously and wait for them to be ready + */ + public static void startContainersSynchronously() { + log.info("Starting TestContainers synchronously..."); + + try { + // Start Elasticsearch container + log.info("Starting Elasticsearch container..."); + ELASTICSEARCH_CONTAINER.start(); + log.info("Elasticsearch container started successfully on {}:{}", + ELASTICSEARCH_CONTAINER.getHost(), ELASTICSEARCH_CONTAINER.getMappedPort(9200)); + + // Wait for containers to be ready and healthy + waitForContainersToBeReady(); + + } catch (Exception e) { + log.error("Failed to start TestContainers", e); + throw new RuntimeException("Failed to start TestContainers", e); + } + } + + /** + * Wait for containers to be ready + */ + private static void waitForContainersToBeReady() { + try { + log.info("Waiting for Elasticsearch to be fully operational..."); + + // Wait for Elasticsearch cluster to be healthy using the health checker + boolean elasticsearchReady = ContainerHealthChecker.waitForContainerHealth( + "Elasticsearch", + () -> ContainerHealthChecker.isElasticsearchHealthy( + ELASTICSEARCH_CONTAINER.getHost(), + ELASTICSEARCH_CONTAINER.getMappedPort(9200) + ), + 30, // max attempts + 2000 // delay between attempts in ms + ); + + if (!elasticsearchReady) { + log.error("Elasticsearch failed to become ready. Container status: {}", getContainerStatus()); + throw new RuntimeException("Elasticsearch failed to become ready within expected time"); + } + + log.info("Elasticsearch is fully operational"); + + // Both containers are now ready, set the flag and notify waiting threads + synchronized (containerLock) { + containersReady = true; + containerLock.notifyAll(); + log.info("Both containers are now ready and healthy - notifying waiting threads"); + } + + } catch (Exception e) { + log.error("Failed to wait for containers to be ready. Container status: {}", getContainerStatus(), e); + throw new RuntimeException("Failed to wait for containers to be ready", e); + } + } + + /** + * Wait for containers to be ready, blocking until they are + */ + public static void waitForContainersReady() { + synchronized (containerLock) { + while (!containersReady) { + try { + log.info("Waiting for TestContainers to be ready..."); + containerLock.wait(30000); // Wait up to 10 seconds at a time + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for containers", e); + } + } + } + } + + /** + * Check if containers are ready, throwing an exception if not + */ + public static void ensureContainersReady() { + if (!containersReady) { + throw new IllegalStateException("TestContainers are not ready yet. Call waitForContainersReady() first."); + } + } + + /** + * Check if containers are running + */ + public static boolean areContainersRunning() { + return ELASTICSEARCH_CONTAINER.isRunning(); + } + + /** + * Check if containers are ready + */ + public static boolean areContainersReady() { + return containersReady; + } + + /** + * Check if the containers are healthy and ready for testing + */ + public static boolean areContainersHealthy() { + if (!containersReady) { + return false; + } + + try { + // Check if Elasticsearch is healthy + boolean elasticsearchHealthy = ContainerHealthChecker.isElasticsearchHealthy( + ELASTICSEARCH_CONTAINER.getHost(), + ELASTICSEARCH_CONTAINER.getMappedPort(9200) + ); + + return elasticsearchHealthy; + + } catch (Exception e) { + log.warn("Error checking container health", e); + return false; + } + } + + /** + * Wait for containers to be healthy, blocking until they are + */ + public static void waitForContainersHealthy() { + while (!areContainersHealthy()) { + try { + log.info("Waiting for containers to become healthy..."); + Thread.sleep(10000); // Wait 10 seconds between checks + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for containers to become healthy", e); + } + } + log.info("All containers are healthy and ready for testing"); + } + + /** + * Get detailed container status information for debugging + */ + public static String getContainerStatus() { + StringBuilder status = new StringBuilder(); + status.append("Container Status:\n"); + + if (ELASTICSEARCH_CONTAINER != null) { + status.append("Elasticsearch: "); + status.append(ELASTICSEARCH_CONTAINER.isRunning() ? "Running" : "Not Running"); + if (ELASTICSEARCH_CONTAINER.isRunning()) { + status.append(" on ").append(ELASTICSEARCH_CONTAINER.getHost()) + .append(":").append(ELASTICSEARCH_CONTAINER.getMappedPort(9200)); + } + status.append("\n"); + } else { + status.append("Elasticsearch: Not initialized\n"); + } + + status.append("Containers Ready: ").append(containersReady); + + return status.toString(); + } + + /** + * Shutdown all TestContainers + */ + public static void shutdownContainers() { + log.info("Shutting down TestContainers..."); + + try { + if (ELASTICSEARCH_CONTAINER != null && ELASTICSEARCH_CONTAINER.isRunning()) { + ELASTICSEARCH_CONTAINER.stop(); + log.info("Elasticsearch container stopped"); + } + + synchronized (containerLock) { + containersReady = false; + } + + log.info("All TestContainers stopped successfully"); + + } catch (Exception e) { + log.warn("Error during container shutdown", e); + } + } + + /** + * Get the Elasticsearch URL + */ + public static String getElasticsearchUrl() { + return "http://" + ELASTICSEARCH_CONTAINER.getHost() + ":" + ELASTICSEARCH_CONTAINER.getMappedPort(9200); + } + +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestContainer.java b/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestContainer.java deleted file mode 100644 index ea6927f14..000000000 --- a/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestContainer.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.kinotic.structuresserver.config; - -import org.testcontainers.elasticsearch.ElasticsearchContainer; - -public class ElasticsearchTestContainer extends ElasticsearchContainer { - public ElasticsearchTestContainer(String dockerImageName) { - super(dockerImageName); - } - public static ElasticsearchTestContainer create() { - return new ElasticsearchTestContainer("docker.elastic.co/elasticsearch/elasticsearch:7.17.9"); - } -} \ No newline at end of file diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestContextInitializer.java b/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestContextInitializer.java new file mode 100644 index 000000000..cf6757acd --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/config/ElasticsearchTestContextInitializer.java @@ -0,0 +1,56 @@ +package org.kinotic.structuresserver.config; + +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TestContextInitializer that ensures TestContainers are ready before Spring context initialization. + * This works in conjunction with TestBeanPostProcessor to provide a complete test setup. + * + * The TestBeanPostProcessor handles the detailed container setup and property injection, + * while this initializer ensures containers are started early in the context lifecycle. + */ +public class ElasticsearchTestContextInitializer implements ApplicationContextInitializer { + + private static final Logger log = LoggerFactory.getLogger(ElasticsearchTestContextInitializer.class); + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + log.info("ElasticsearchTestContextInitializer: Ensuring TestContainers are ready before Spring context initialization..."); + + try { + // Ensure containers are started and ready before Spring context creation + // This is a lightweight check - TestBeanPostProcessor will handle the detailed setup + if (!ElasticsearchTestConfiguration.areContainersRunning()) { + log.info("ElasticsearchTestContextInitializer: Starting TestContainers..."); + ElasticsearchTestConfiguration.startContainersSynchronously(); + } else if (!ElasticsearchTestConfiguration.areContainersReady()) { + log.info("ElasticsearchTestContextInitializer: Waiting for containers to be ready..."); + ElasticsearchTestConfiguration.waitForContainersReady(); + } + + log.info("ElasticsearchTestContextInitializer: TestContainers are ready, proceeding with Spring context initialization"); + + ElasticsearchTestConfiguration.ensureContainersReady(); + + // Elasticsearch properties + TestPropertyValues.of("structures.elastic-connections[0].host=" + ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getHost()) + .applyTo(applicationContext); + TestPropertyValues.of("structures.elastic-connections[0].port=" + ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getMappedPort(9200)) + .applyTo(applicationContext); + TestPropertyValues.of("structures.elastic-connections[0].scheme=http") + .applyTo(applicationContext); + TestPropertyValues.of("elasticsearch.test.hostname="+ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getHost()) + .applyTo(applicationContext); + TestPropertyValues.of("elasticsearch.test.port="+ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getMappedPort(9200)) + .applyTo(applicationContext); + + } catch (Exception e) { + log.error("ElasticsearchTestContextInitializer: Failed to ensure TestContainers are ready", e); + throw new RuntimeException("TestContainers failed to start during context initialization", e); + } + } +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/config/TestConfiguration.java b/structures-server/src/test/java/org/kinotic/structuresserver/config/TestConfiguration.java deleted file mode 100644 index 956bad8dd..000000000 --- a/structures-server/src/test/java/org/kinotic/structuresserver/config/TestConfiguration.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.kinotic.structuresserver.config; - -import org.kinotic.continuum.api.annotations.EnableContinuum; -import org.kinotic.continuum.gateway.api.annotations.EnableContinuumGateway; -import org.kinotic.structures.api.annotations.EnableStructures; -import org.springframework.boot.test.util.TestPropertyValues; -import org.springframework.context.ApplicationContextInitializer; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; - -@Configuration -@Profile("test") // Only load this configuration during tests -@EnableContinuum -@EnableStructures -@EnableContinuumGateway -public class TestConfiguration { - public static final ElasticsearchTestContainer ELASTICSEARCH_CONTAINER; - static { - ELASTICSEARCH_CONTAINER = ElasticsearchTestContainer.create(); - ELASTICSEARCH_CONTAINER.start(); - } - public static class Initializer implements ApplicationContextInitializer { - @Override - public void initialize(ConfigurableApplicationContext configurableApplicationContext) { - TestPropertyValues.of("spring.data.elasticsearch.cluster-nodes=" + ELASTICSEARCH_CONTAINER.getHttpHostAddress()) - .applyTo(configurableApplicationContext.getEnvironment()); - TestPropertyValues.of("structures.elastic-uris=" + ELASTICSEARCH_CONTAINER.getHttpHostAddress()) - .applyTo(configurableApplicationContext.getEnvironment()); - } - } -} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/tests/ApplicationStartupContainerTest.java b/structures-server/src/test/java/org/kinotic/structuresserver/tests/ApplicationStartupContainerTest.java new file mode 100644 index 000000000..6a1b195b5 --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/tests/ApplicationStartupContainerTest.java @@ -0,0 +1,13 @@ +package org.kinotic.structuresserver.tests; + +import org.junit.jupiter.api.Test; +import org.kinotic.structuresserver.ElasticTestBase; + + +public class ApplicationStartupContainerTest extends ElasticTestBase { + + @Test + public void applicationStarts() { + + } +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/tests/TestBase.java b/structures-server/src/test/java/org/kinotic/structuresserver/tests/TestBase.java deleted file mode 100644 index 75a6f4aba..000000000 --- a/structures-server/src/test/java/org/kinotic/structuresserver/tests/TestBase.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.kinotic.structuresserver.tests; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.TestInstance; -import org.kinotic.structuresserver.config.TestConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ContextConfiguration; - - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -@SpringBootTest -@ContextConfiguration(initializers = TestConfiguration.Initializer.class, classes = TestConfiguration.class) -public abstract class TestBase { - - @BeforeAll - public void setUp(){ - } - - @AfterAll - public void tearDown() { - - } - -} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/tests/TestContext.java b/structures-server/src/test/java/org/kinotic/structuresserver/tests/TestContext.java deleted file mode 100644 index 4db51247a..000000000 --- a/structures-server/src/test/java/org/kinotic/structuresserver/tests/TestContext.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.kinotic.structuresserver.tests; - -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -@ExtendWith(SpringExtension.class) -@SpringBootTest -public class TestContext extends TestBase { -} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/tests/config/ContainerHealthCheckerTest.java b/structures-server/src/test/java/org/kinotic/structuresserver/tests/config/ContainerHealthCheckerTest.java new file mode 100644 index 000000000..f1afa8793 --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/tests/config/ContainerHealthCheckerTest.java @@ -0,0 +1,67 @@ +package org.kinotic.structuresserver.tests.config; + +import org.junit.jupiter.api.Test; +import org.kinotic.structuresserver.config.ContainerHealthChecker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.*; + +public class ContainerHealthCheckerTest { + + private static final Logger log = LoggerFactory.getLogger(ContainerHealthCheckerTest.class); + + @Test + public void testElasticsearchHealthCheckWithInvalidHost() { + // Test that Elasticsearch health check handles invalid host gracefully + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy("invalid-host", 9200); + assertFalse(isHealthy, "Health check should return false for invalid host"); + } + + @Test + public void testKeycloakHealthCheckWithInvalidHost() { + // Test that Keycloak health check handles invalid host gracefully + boolean isHealthy = ContainerHealthChecker.isKeycloakHealthy("invalid-host", 8080); + assertFalse(isHealthy, "Health check should return false for invalid host"); + } + + @Test + public void testHealthCheckWithInvalidHost() { + // Test health check with invalid host (should return false, not throw exception) + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy("invalid-host", 9200); + assertFalse(isHealthy, "Health check should return false for invalid host"); + } + + @Test + public void testHealthCheckWithInvalidPort() { + // Test health check with invalid port (should return false, not throw exception) + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy("localhost", 9999); + assertFalse(isHealthy, "Health check should return false for invalid port"); + } + + @Test + public void testWaitForContainerHealth() { + // Test the wait mechanism with a simple health check + boolean result = ContainerHealthChecker.waitForContainerHealth( + "TestContainer", + () -> true, // Always return true + 1, // Only 1 attempt needed + 100 // 100ms delay + ); + + assertTrue(result, "Wait should succeed for always-healthy container"); + } + + @Test + public void testWaitForContainerHealthWithFailure() { + // Test the wait mechanism with a failing health check + boolean result = ContainerHealthChecker.waitForContainerHealth( + "TestContainer", + () -> false, // Always return false + 3, // 3 attempts + 100 // 100ms delay + ); + + assertFalse(result, "Wait should fail for always-unhealthy container"); + } +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/tests/config/TestConfigurationTest.java b/structures-server/src/test/java/org/kinotic/structuresserver/tests/config/TestConfigurationTest.java new file mode 100644 index 000000000..4e24f6160 --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/tests/config/TestConfigurationTest.java @@ -0,0 +1,79 @@ +package org.kinotic.structuresserver.tests.config; + +import org.junit.jupiter.api.Test; +import org.kinotic.structuresserver.config.ContainerHealthChecker; +import org.kinotic.structuresserver.config.ElasticsearchTestConfiguration; +import org.kinotic.structuresserver.ElasticTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestConfigurationTest extends ElasticTestBase { + + @Test + public void testTestContainersStarted() { + // Verify that TestContainers are running + assertNotNull(ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER); + assertTrue(ElasticsearchTestConfiguration.areContainersRunning()); + assertTrue(ElasticsearchTestConfiguration.areContainersReady()); + } + + @Test + public void testElasticsearchUrlGenerated() { + // Verify that Elasticsearch URL is generated correctly + String elasticsearchUrl = ElasticsearchTestConfiguration.getElasticsearchUrl(); + assertNotNull(elasticsearchUrl); + assertTrue(elasticsearchUrl.startsWith("http://")); + assertTrue(elasticsearchUrl.contains(":")); + } + + @Test + public void testElasticsearchServiceAccessible() { + // Verify that Elasticsearch service is accessible + String host = ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getHost(); + Integer port = ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getMappedPort(9200); + + assertNotNull(host); + assertNotNull(port); + assertTrue(port > 0); + } + + @Test + public void testContainersAreRunning() { + // Containers should already be ready from TestBase.setUp() + assertTrue(ElasticsearchTestConfiguration.areContainersRunning(), "Containers should be running"); + assertTrue(ElasticsearchTestConfiguration.areContainersReady(), "Containers should be ready"); + } + + @Test + public void testElasticsearchUrl() { + String url = ElasticsearchTestConfiguration.getElasticsearchUrl(); + assertNotNull(url); + assertTrue(url.startsWith("http://")); + assertTrue(url.contains(":")); + } + + @Test + public void testContainersAreHealthy() { + // Containers should already be healthy from TestBase.setUp() + assertTrue(ElasticsearchTestConfiguration.areContainersHealthy(), "Containers should be healthy"); + } + + @Test + public void testContainerStatus() { + String status = ElasticsearchTestConfiguration.getContainerStatus(); + assertNotNull(status); + assertTrue(status.contains("Elasticsearch")); + assertTrue(status.contains("Containers Ready: true")); + } + + @Test + public void testElasticsearchHealthCheck() { + // Test that Elasticsearch health check works + boolean isHealthy = ContainerHealthChecker.isElasticsearchHealthy( + ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getHost(), + ElasticsearchTestConfiguration.ELASTICSEARCH_CONTAINER.getMappedPort(9200) + ); + assertTrue(isHealthy, "Elasticsearch should be healthy"); + } + +} diff --git a/structures-server/src/test/java/org/kinotic/structuresserver/tests/frontend/FrontendConfigurationIntegrationTest.java b/structures-server/src/test/java/org/kinotic/structuresserver/tests/frontend/FrontendConfigurationIntegrationTest.java new file mode 100644 index 000000000..de65351ba --- /dev/null +++ b/structures-server/src/test/java/org/kinotic/structuresserver/tests/frontend/FrontendConfigurationIntegrationTest.java @@ -0,0 +1,175 @@ +package org.kinotic.structuresserver.tests.frontend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.kinotic.structures.api.config.StructuresProperties; +import org.kinotic.structures.auth.api.config.OidcSecurityServiceProperties; +import org.kinotic.structuresserver.ElasticTestBase; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test for frontend configuration generation. + * This test boots up the actual application and tests the real configuration endpoint. + */ +public class FrontendConfigurationIntegrationTest extends ElasticTestBase { + + @Value("${server.port}") + private int port = 8990; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private OidcSecurityServiceProperties oidcSecurityServiceProperties; + @Autowired + private StructuresProperties structuresProperties; + + private String getURL() { + return String.format("http://localhost:%d/%s", structuresProperties.getWebServerPort() + 1, oidcSecurityServiceProperties.getFrontendConfigurationPath()); + } + + @Test + public void testFrontendConfigurationEndpoint_ReturnsValidConfiguration() throws Exception { + // Given: Application is running with test profile + + // When: Request the frontend configuration endpoint + ResponseEntity response = restTemplate.getForEntity(getURL(), String.class); + + // Then: Response should be successful + assertTrue(response.getStatusCode().is2xxSuccessful(), + "Expected successful response, got: " + response.getStatusCode()); + assertNotNull(response.getBody(), "Response body should not be null"); + + // Parse the JSON response + JsonNode config = objectMapper.readTree(response.getBody()); + + // Validate basic structure - the config is the FrontendConfiguration object directly + assertTrue(config.has("enabled"), "Configuration should have 'enabled' field"); + assertTrue(config.has("oidcProviders"), "Configuration should have 'oidcProviders' section"); + assertTrue(config.has("debug"), "Configuration should have 'debug' field"); + + // Validate OIDC providers - now they're in a list + JsonNode oidcProviders = config.get("oidcProviders"); + assertTrue(oidcProviders.isArray(), "oidcProviders should be an array"); + + // Find specific providers in the list + JsonNode keycloakProvider = findProviderByName(oidcProviders, "keycloak"); + assertNotNull(keycloakProvider, "Keycloak provider should be present"); + assertTrue(keycloakProvider.get("enabled").asBoolean(), "Keycloak provider should be enabled"); + assertEquals("test-keycloak-client", keycloakProvider.get("clientId").asText(), "Keycloak client ID should match test config"); + assertEquals("http://localhost:8888/auth/realms/test", keycloakProvider.get("authority").asText(), "Keycloak authority should match test config"); + + // Validate debug flag (should match test configuration) + assertTrue(config.get("debug").asBoolean(), "Debug should be enabled based on test config"); + + // Validate redirect URIs for enabled providers + String expectedBaseUrl = "http://localhost:8989"; + + assertEquals(expectedBaseUrl + "/login", keycloakProvider.get("redirectUri").asText(), "Keycloak redirect URI should be correct"); + assertEquals(expectedBaseUrl, keycloakProvider.get("postLogoutRedirectUri").asText(), "Keycloak post-logout redirect URI should be correct"); + assertEquals(expectedBaseUrl + "/login/silent-renew", keycloakProvider.get("silentRedirectUri").asText(), "Keycloak silent redirect URI should be correct"); + } + + @Test + public void testFrontendConfigurationEndpoint_ResponseHeaders() { + // Given: Application is running with test profile + + ResponseEntity response = restTemplate.getForEntity(getURL(), String.class); + + // Then: Response headers should be correct + assertTrue(response.getStatusCode().is2xxSuccessful()); + + // Check content type + assertTrue(response.getHeaders().getContentType().toString().contains("application/json"), + "Content-Type should be application/json"); + + // Check cache control headers + String cacheControl = response.getHeaders().getFirst("Cache-Control"); + assertNotNull(cacheControl, "Cache-Control header should be present"); + assertTrue(cacheControl.contains("no-cache"), "Cache-Control should contain no-cache"); + assertTrue(cacheControl.contains("no-store"), "Cache-Control should contain no-store"); + assertTrue(cacheControl.contains("must-revalidate"), "Cache-Control should contain must-revalidate"); + + String pragma = response.getHeaders().getFirst("Pragma"); + assertNotNull(pragma, "Pragma header should be present"); + assertEquals("no-cache", pragma, "Pragma should be no-cache"); + + String expires = response.getHeaders().getFirst("Expires"); + assertNotNull(expires, "Expires header should be present"); + assertEquals("0", expires, "Expires should be 0"); + } + + @Test + public void testFrontendConfigurationEndpoint_JsonStructure() throws Exception { + // Given: Application is running with test profile + + // When: Request the frontend configuration endpoint + ResponseEntity response = restTemplate.getForEntity(getURL(), String.class); + + // Then: Response should be valid JSON + assertTrue(response.getStatusCode().is2xxSuccessful()); + String responseBody = response.getBody(); + assertNotNull(responseBody); + + // Verify it's valid JSON by parsing it + JsonNode config = objectMapper.readTree(responseBody); + + // Verify all required fields are present and have correct types + assertTrue(config.isObject(), "Root should be a JSON object"); + assertTrue(config.get("enabled").isBoolean(), "enabled should be a boolean"); + assertTrue(config.get("oidcProviders").isArray(), "oidcProviders should be an array"); + assertTrue(config.get("debug").isBoolean(), "debug should be a boolean"); + + // Verify OIDC provider structure + JsonNode oidcProviders = config.get("oidcProviders"); + for (JsonNode provider : oidcProviders) { + assertTrue(provider.has("enabled"), "Provider should have 'enabled' field"); + assertTrue(provider.has("provider"), "Provider should have 'provider' field"); + assertTrue(provider.has("displayName"), "Provider should have 'displayName' field"); + assertTrue(provider.has("clientId"), "Provider should have 'clientId' field"); + assertTrue(provider.has("authority"), "Provider should have 'authority' field"); + assertTrue(provider.has("redirectUri"), "Provider should have 'redirectUri' field"); + assertTrue(provider.has("postLogoutRedirectUri"), "Provider should have 'postLogoutRedirectUri' field"); + assertTrue(provider.has("silentRedirectUri"), "Provider should have 'silentRedirectUri' field"); + } + + } + + @Test + public void testFrontendConfigurationEndpoint_EndpointAccessibility() { + // Given: Application is running with test profile + + // When: Request the frontend configuration endpoint + ResponseEntity response = restTemplate.getForEntity(getURL(), String.class); + + // Then: Endpoint should be accessible and return valid JSON + assertTrue(response.getStatusCode().is2xxSuccessful(), + "Endpoint should be accessible, got status: " + response.getStatusCode()); + assertNotNull(response.getBody(), "Response body should not be null"); + assertFalse(response.getBody().isEmpty(), "Response body should not be empty"); + + // Verify it's valid JSON by checking it starts with { + assertTrue(response.getBody().trim().startsWith("{"), + "Response should be valid JSON starting with {"); + assertTrue(response.getBody().trim().endsWith("}"), + "Response should be valid JSON ending with }"); + } + + /** + * Helper method to find a provider by name in the oidcProviders array + */ + private JsonNode findProviderByName(JsonNode oidcProviders, String providerName) { + for (JsonNode provider : oidcProviders) { + if (providerName.equals(provider.get("provider").asText())) { + return provider; + } + } + return null; + } +} diff --git a/structures-server/src/test/resources/application-test.yml b/structures-server/src/test/resources/application-test.yml new file mode 100644 index 000000000..ea8c906f2 --- /dev/null +++ b/structures-server/src/test/resources/application-test.yml @@ -0,0 +1,30 @@ +# Test configuration for frontend configuration generation + +# Frontend configuration settings +structures: + initialize-with-sample-data: true + enable-static-file-server: true + web-server-port: 8989 + cors-allowed-origin-pattern: "*" + cors-allowed-headers: "*" + cors-allow-credentials: true + +# OIDC security service configuration +oidc-security-service: + enabled: true + debug: true + frontend-configuration-path: "/app-config.override.json" + oidc-providers: + - provider: "keycloak" + display-name: "Keycloak" + enabled: true + roles-claim-path: "realm_access.roles" + domains: + - "example.com" + audience: "test-keycloak-client" + client-id: "test-keycloak-client" + authority: "http://localhost:8888/auth/realms/test" + redirect-uri: "http://localhost:8989/login" + post-logout-redirect-uri: "http://localhost:8989" + silent-redirect-uri: "http://localhost:8989/login/silent-renew" + \ No newline at end of file diff --git a/structures-server/src/test/resources/application.yml b/structures-server/src/test/resources/application.yml index 92a07245c..fa2045194 100644 --- a/structures-server/src/test/resources/application.yml +++ b/structures-server/src/test/resources/application.yml @@ -1,13 +1,25 @@ spring: + ai: + openai: + api-key: "test-key" + base-url: https://api.x.ai + chat: + options: + model: grok-4 main: allow-circular-references: true + allow-bean-definition-overriding: true + continuum: discovery: sharedfs debug: true + maxNumberOfCoresToUse: 4 + continuum-gateway: - disableIam: true + disable-iam: true + logging: level: @@ -18,8 +30,6 @@ logging: io: vertx: DEBUG -structures: - elastic-use-ssl: false - server: port: 8989 + diff --git a/structures-sql/src/test/java/org/kinotic/structures/sql/ElasticsearchSqlTestBase.java b/structures-sql/src/test/java/org/kinotic/structures/sql/ElasticsearchSqlTestBase.java index 4b58068b1..eda1f1ca3 100644 --- a/structures-sql/src/test/java/org/kinotic/structures/sql/ElasticsearchSqlTestBase.java +++ b/structures-sql/src/test/java/org/kinotic/structures/sql/ElasticsearchSqlTestBase.java @@ -17,7 +17,7 @@ public abstract class ElasticsearchSqlTestBase { String osName = System.getProperty("os.name"); String osArch = System.getProperty("os.arch"); - ELASTICSEARCH_CONTAINER = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.17.3"); + ELASTICSEARCH_CONTAINER = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.18.1"); ELASTICSEARCH_CONTAINER.withEnv("discovery.type", "single-node") .withEnv("xpack.security.enabled", "false"); diff --git a/structures-sql/src/test/resources/application.yaml b/structures-sql/src/test/resources/application.yaml new file mode 100644 index 000000000..2b1c825f7 --- /dev/null +++ b/structures-sql/src/test/resources/application.yaml @@ -0,0 +1,3 @@ + +structures-sql-test: + enabled: true \ No newline at end of file diff --git a/webdocs/cursorrules b/webdocs/cursorrules new file mode 100644 index 000000000..fb1397190 --- /dev/null +++ b/webdocs/cursorrules @@ -0,0 +1,43 @@ +# Webdocs Documentation Module + +## Overview +Comprehensive documentation for the Structures project including guides, API references, and examples. + +## Documentation Structure +- **guide/**: Getting started and feature guides +- **reference/**: API documentation and technical references +- **examples/**: Code examples and tutorials +- **graphos/**: GraphQL federation documentation + +## Documentation Standards +1. **Markdown**: Use consistent markdown formatting +2. **Code Examples**: Include working code examples +3. **Cross-References**: Link between related documentation +4. **Versioning**: Keep documentation in sync with code +5. **Screenshots**: Include relevant screenshots for UI features + +## Key Topics +- **OIDC Authentication**: Complete implementation guide +- **GraphQL Federation**: Schema composition and decorators +- **API Reference**: REST and GraphQL API documentation +- **Deployment**: Docker, Kubernetes, and Helm charts +- **Development**: Setup and contribution guidelines + +## Writing Guidelines +- Use clear, concise language +- Include step-by-step instructions +- Provide configuration examples +- Document troubleshooting steps +- Keep examples up-to-date with code + +## Code Examples +- Include syntax highlighting +- Provide complete, runnable examples +- Show both success and error cases +- Include environment-specific configurations + +## Maintenance +- Update documentation with code changes +- Review and update examples regularly +- Validate links and references +- Ensure accuracy of configuration examples \ No newline at end of file diff --git a/webdocs/guide/getting-started.md b/webdocs/guide/getting-started.md index f9472a08f..d72942830 100644 --- a/webdocs/guide/getting-started.md +++ b/webdocs/guide/getting-started.md @@ -187,3 +187,40 @@ export class Person { ## Next Steps - Explore the [Decorators Reference](/reference/decorators) for available decorators and options +## Testing + +### Running Tests Locally +When running tests locally, you may encounter issues with TestContainers (used for Elasticsearch testing). To resolve this, set the following environment variable: + +```bash +export TESTCONTAINERS_RYUK_DISABLED=true +``` + +### Why This is Required +TestContainers uses a Ryuk container for resource cleanup, which can cause connectivity issues on certain systems, particularly: +- macOS with Docker Desktop +- Some CI/CD environments +- Systems with strict firewall rules + +Disabling Ryuk ensures reliable test execution by skipping the cleanup container. + +### CI/CD Configuration +If you're running tests in a CI/CD pipeline, add this environment variable to your pipeline configuration: + +```yaml +# GitHub Actions example +env: + TESTCONTAINERS_RYUK_DISABLED: true + +# GitLab CI example +variables: + TESTCONTAINERS_RYUK_DISABLED: "true" + +# Jenkins example +environment { + TESTCONTAINERS_RYUK_DISABLED = 'true' +} +``` + +This ensures that all tests, including those requiring Elasticsearch containers, run successfully in your automated testing environment. +