diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..155c0708 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,432 @@ +# Authentication + +This guide covers authentication configuration for ExploitIQ Client, including OpenShift OAuth, external identity providers, and development setups. + +## Overview + +ExploitIQ supports multiple authentication modes via Quarkus profiles: + +| Profile | Use Case | Identity Provider | +|---------|----------|-------------------| +| `prod` | OpenShift | OpenShift OAuth | +| `external-idp` | External identity providers | Keycloak, Google, Azure AD, Okta | +| `dev` | Local development | Keycloak DevServices | + +### Authentication Methods + +All profiles support both browser and API authentication: + +| Method | Use Case | Flow | +|--------|----------|------| +| Browser | Web UI | Authorization Code Flow (redirects to IdP) | +| API | CLI, scripts, services | Bearer JWT token in `Authorization` header | + +**Token acquisition differs by profile:** + +- `prod` (OpenShift): Use `oc whoami -t` or ServiceAccount tokens +- `external-idp` (Keycloak): Use OIDC token endpoint with password grant +- `dev`: Same as `external-idp` (DevServices Keycloak) + +## OpenShift OAuth (Production) + +The default production configuration uses OpenShift's built-in OAuth server. + +### Prerequisites + +Create an `OAuthClient` resource in your OpenShift cluster: + +```yaml +apiVersion: oauth.openshift.io/v1 +kind: OAuthClient +metadata: + name: exploit-iq-client +grantMethod: prompt +secret: +redirectURIs: + - "https://exploit-iq-client." +``` + +### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `OPENSHIFT_DOMAIN` | OpenShift cluster domain | `example.openshift.com` | +| `OAUTH_CLIENT_SECRET` | Secret from OAuthClient resource | `` | + +### Deployment Configuration + +```yaml +spec: + containers: + - name: exploit-iq-client + env: + - name: OPENSHIFT_DOMAIN + valueFrom: + secretKeyRef: + name: oauth-config + key: domain + - name: OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: oauth-config + key: secret +``` + +### API Access (prod profile) + +For API access in OpenShift, use your user token: + +```bash +# After oc login +TOKEN=$(oc whoami -t) +curl -H "Authorization: Bearer $TOKEN" https://exploit-iq-client.apps.example.com/api/v1/reports +``` + +## External Identity Providers + +Use the `external-idp` profile to integrate with external OIDC providers. + +### Keycloak + +Keycloak can be used standalone or as an identity broker for GitHub, Google, and other providers. + +#### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `QUARKUS_PROFILE` | Must be `external-idp` | `external-idp` | +| `QUARKUS_OIDC_AUTH_SERVER_URL` | Keycloak realm URL | `https://keycloak.example.com/realms/` | +| `QUARKUS_OIDC_CREDENTIALS_SECRET` | OIDC client secret | `` | + +**Note:** The testing script uses `quarkus` as the default realm name. Replace with your actual realm name in production. + +#### Keycloak Client Configuration + +Create an OIDC client in Keycloak with the following settings: + +```json +{ + "clientId": "exploit-iq-client", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "", + "redirectUris": ["https://your-app-url/*"], + "webOrigins": ["https://your-app-url"], + "publicClient": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true +} +``` + +**Important:** `directAccessGrantsEnabled: true` is required for API authentication via password grant. + +Required protocol mappers (add to client scope): + +- `preferred_username`: Maps `username` to `preferred_username` claim +- `email`: Maps `email` to `email` claim +- `upn`: Maps `username` to `upn` claim (fallback) + +### Direct Google OIDC + +Connect directly to Google without Keycloak. + +#### Prerequisites + +1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +2. Create OAuth 2.0 Client ID (Web application) +3. Add authorized redirect URI: `https://your-app-url/` + +#### Environment Variables + +| Variable | Description | +|----------|-------------| +| `QUARKUS_PROFILE` | `external-idp` | +| `QUARKUS_OIDC_PROVIDER` | `google` | +| `QUARKUS_OIDC_CLIENT_ID` | Google Client ID | +| `QUARKUS_OIDC_CREDENTIALS_SECRET` | Google Client Secret | + +#### Deployment Example + +```yaml +env: +- name: QUARKUS_PROFILE + value: "external-idp" +- name: QUARKUS_OIDC_PROVIDER + value: "google" +- name: QUARKUS_OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: google-oauth + key: client-id +- name: QUARKUS_OIDC_CREDENTIALS_SECRET + valueFrom: + secretKeyRef: + name: google-oauth + key: client-secret +``` + +### Other OIDC Providers + +The same approach works with any OIDC-compliant provider: + +| Provider | Auth Server URL | +|----------|-----------------| +| Azure AD | `https://login.microsoftonline.com/{tenant}/v2.0` | +| Okta | `https://dev-xxxxx.okta.com/oauth2/default` | +| Auth0 | `https://your-domain.auth0.com` | +| AWS Cognito | `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` | + +**Note:** GitHub does not support OIDC. Use Keycloak as an identity broker for GitHub authentication. + +## API Authentication with JWT (external-idp) + +When using Keycloak or other OIDC providers, you can obtain tokens via the standard OIDC token endpoint. This allows CLI tools, scripts, and external services to authenticate without browser interaction. + +### Obtaining a User Token + +Use the password grant to obtain a token for a specific user: + +```bash +# Configuration (match your Keycloak setup) +KC_URL="http://localhost:8190" # Keycloak URL +KC_REALM="quarkus" # Realm name +CLIENT_ID="exploit-iq-client" # Client ID +CLIENT_SECRET="example-credentials" # Client secret +USERNAME="bruce" # User +PASSWORD="wayne" # Password + +# Get user token (scope=openid is REQUIRED) +USER_TOKEN=$(curl -s -X POST \ + "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}" \ + -d "username=${USERNAME}" \ + -d "password=${PASSWORD}" \ + -d "grant_type=password" \ + -d "scope=openid profile email" | jq -r '.access_token') + +# Verify token was obtained +echo "Token: ${USER_TOKEN:0:50}..." +``` + +**Important:** The `scope=openid profile email` parameter is required. Without `openid`, the UserInfo endpoint will reject the token with "Missing openid scope" error. + +### Making API Requests + +Use the token in the `Authorization` header: + +```bash +# List reports +curl -H "Authorization: Bearer $USER_TOKEN" \ + http://localhost:8080/api/v1/reports + +# Get specific report +curl -H "Authorization: Bearer $USER_TOKEN" \ + http://localhost:8080/api/v1/reports/{id} +``` + +### Service-to-Service Authentication (Optional) + +For machine-to-machine communication, use the client credentials grant: + +```bash +# Get service token +SERVICE_TOKEN=$(curl -s -X POST \ + "${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" \ + -d "client_id=${SERVICE_CLIENT_ID}" \ + -d "client_secret=${SERVICE_SECRET}" \ + -d "grant_type=client_credentials" | jq -r '.access_token') + +curl -H "Authorization: Bearer $SERVICE_TOKEN" \ + http://localhost:8080/api/v1/reports +``` + +**Note:** Requires a separate Keycloak client configured for service accounts. + +### Token Validation + +The application validates JWT tokens by: + +1. Verifying the signature using JWKS from the IdP +2. Checking token expiration +3. Validating the issuer (`iss` claim) +4. Fetching UserInfo to extract user details + +## Identity Brokering with Keycloak + +Keycloak can act as an identity broker, allowing users to authenticate via external providers while maintaining centralized user management. + +### Architecture + +``` +User → Application → Keycloak (Broker) → External IdP (GitHub/Google) + ↓ + Token Issuance + ↓ + Application +``` + +### GitHub Identity Broker + +1. Create GitHub OAuth App at [GitHub Developer Settings](https://github.com/settings/applications/new) +2. Set callback URL: `https:///realms//broker/github/endpoint` +3. Configure in Keycloak: Identity Providers → Add GitHub +4. Add mappers: + - `login` → `preferred_username` + - `email` → `email` + +### Google Identity Broker + +1. Create Google OAuth Client at [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +2. Set redirect URI: `https:///realms//broker/google/endpoint` +3. Configure in Keycloak: Identity Providers → Add Google +4. Add mappers: + - `email` → `email` + +## Local Development + +### DevServices (Automatic) + +Quarkus automatically starts Keycloak via DevServices: + +```bash +./mvnw quarkus:dev +``` + +Test users are pre-configured in `application.properties`: + +```properties +%dev.quarkus.keycloak.devservices.users.bruce=wayne +%dev.quarkus.keycloak.devservices.users.peter=parker +``` + +### External Keycloak (Manual) + +For testing with an external Keycloak instance: + +```bash +# Start Keycloak (use podman or docker) +podman run -d --name keycloak \ + -p 8190:8080 \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + -e KC_HTTP_ENABLED=true \ + -e KC_HOSTNAME=localhost \ + quay.io/keycloak/keycloak:26.4 start-dev + +# Start application (using 'quarkus' as example realm name) +./mvnw quarkus:dev \ + -Dquarkus.profile=external-idp \ + -Dquarkus.oidc.auth-server-url=http://localhost:8190/realms/quarkus \ + -Dquarkus.oidc.credentials.secret=example-credentials \ + -Dquarkus.keycloak.devservices.enabled=false +``` + +### Testing Script + +An automated testing script is available for all authentication scenarios: + +```bash +./scripts/test-auth.sh --help +``` + +The script supports DevServices Keycloak, external Keycloak (with optional GitHub/Google brokers), and direct Google OIDC. + +#### Testing API Authentication + +After running a scenario with Keycloak, test API authentication: + +```bash +# 1. Get user token (uses bruce/wayne created by the script) +USER_TOKEN=$(curl -s -X POST \ + "http://localhost:8190/realms/quarkus/protocol/openid-connect/token" \ + -d "client_id=exploit-iq-client" \ + -d "client_secret=example-credentials" \ + -d "username=bruce" \ + -d "password=wayne" \ + -d "grant_type=password" \ + -d "scope=openid profile email" | jq -r '.access_token') + +# 2. Verify token obtained +[ -n "$USER_TOKEN" ] && echo "Token obtained" || echo "Failed to get token" + +# 3. Call API with Bearer token +curl -H "Authorization: Bearer $USER_TOKEN" \ + http://localhost:8080/api/v1/reports +``` + +## User Display + +The application displays user information with this priority: + +1. `email` claim (primary) +2. `upn` claim (User Principal Name) +3. `metadata.name` (OpenShift) +4. `anonymous` (fallback) + +Ensure your identity provider or Keycloak is configured to include the `email` claim in tokens. + +## Troubleshooting + +### User Shows as "anonymous" + +**Cause:** Missing protocol mappers in Keycloak. + +**Solution:** Add `email`, `preferred_username`, and `upn` mappers to the client scope. + +### Redirect URI Mismatch + +**Cause:** The redirect URI in the OAuth app doesn't match the application URL. + +**Solution:** + +- Ensure exact match including trailing slash: `https://your-app/` +- Changes may take 5-15 minutes to propagate + +### API Returns 401 Unauthorized + +**Cause:** Token missing `openid` scope or invalid token. + +**Solution:** + +1. Ensure `scope=openid profile email` is included in token request +2. Verify token is not expired +3. Check Keycloak logs for "Missing openid scope" error + +### HTTPS Required Error (Keycloak) + +**Cause:** Keycloak 26.x requires HTTPS by default, even in development. + +**Solution:** For local development, set `sslRequired=NONE` on the realm: + +```bash +# Using kcadm.sh inside container +podman exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://localhost:8080 --realm master --user admin --password admin +podman exec keycloak /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE +``` + +The testing script (`test-auth.sh`) handles this automatically. + +### Enable Debug Logging + +Add to `application.properties` or set as environment variable: + +```properties +quarkus.log.category."io.quarkus.oidc".level=DEBUG +``` + +Or run the testing script with debug flag: + +```bash +./scripts/test-auth.sh --debug +``` + +## Additional Resources + +- [Quarkus OIDC Guide](https://quarkus.io/guides/security-openid-connect) +- [Quarkus OIDC Bearer Token Authentication](https://quarkus.io/guides/security-oidc-bearer-token-authentication) +- [Quarkus Configuring Well-Known OpenID Connect Providers](https://quarkus.io/guides/security-openid-connect-providers) +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [GitHub OAuth Apps](https://docs.github.com/en/developers/apps/building-oauth-apps) +- [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2) diff --git a/docs/configuration.md b/docs/configuration.md index a3ffe08c..a40d70be 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,34 +2,9 @@ ## Authentication -For development a Keycloak instance will be instantiated with the users defined in the configuration. +For detailed authentication configuration including OpenShift OAuth, Keycloak, and external identity providers (Google, GitHub, Azure AD), see the [Authentication Guide](./authentication.md). -```properties -%dev.quarkus.keycloak.devservices.users.joe=pass123 -``` - -For production the OpenShift OAuth2 provider will be used so it is required to -provide the following environment variables: - -* `OPENSHIFT_DOMAIN`: e.g. `example.openshift.com` -* `OAUTH_CLIENT_SECRET`: With the secret defined for the `agent-morpheus-client` in the OpenShift cluster. - -In the cluster you have to create an `OAuthClient` with the right redirect URLs - -```yaml -apiVersion: oauth.openshift.io/v1 -kind: OAuthClient -metadata: - name: agent-morpheus-client -grantMethod: prompt -secret: some-long-secret-used-by-the-oauth-client -redirectURIs: - - "http://agent-morpheus-client:8080" - - "https://agent-morpheus-client.example.openshift.com" - - "http://agent-morpheus-client.example.openshift.com" -``` - -## External services (GitHub / Morpheus) +## External Services (GitHub / Morpheus) Use the `rest-client` properties for updating the default the github and morpheus RestClient endpoints: diff --git a/docs/development.md b/docs/development.md index fb09b846..60b0deee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,9 @@ ## Configuration -To see all the configuration options check the [configuration](./configuration.md) README. +To see all the configuration options check the [configuration](./configuration.md) guide. + +For authentication setup (Keycloak, external identity providers, testing), see the [authentication](./authentication.md) guide. ## Running the application in dev mode @@ -51,6 +53,29 @@ You can create a native executable using: ./mvnw package -Dnative ``` +### Build profiles + +> **Warning**: Quarkus has build-time properties that are fixed at compile time. If you change +> build-time properties in `application.properties` for a custom profile (e.g., `%external-idp`), +> you must build with that profile to apply them. Runtime properties can be overridden via +> `QUARKUS_PROFILE` env var at startup. + +To build with a specific profile: + +```shell +./mvnw package -Dnative -Dquarkus.profile=external-idp +``` + +Or using container build: + +```shell +# podman +podman build --build-arg QUARKUS_PROFILE=external-idp -f src/main/docker/Dockerfile.multi-stage . + +# docker +docker build --build-arg QUARKUS_PROFILE=external-idp -f src/main/docker/Dockerfile.multi-stage . +``` + Or, if you don't have GraalVM installed, you can run the native executable build in a container using: ```shell @@ -59,6 +84,20 @@ Or, if you don't have GraalVM installed, you can run the native executable build You can then execute your native executable with: `./target/agent-morpheus-client-1.0.0-SNAPSHOT-runner` +### Building with profiles + +Some Quarkus properties are **build-time only** and cannot be changed at runtime. When building for a specific deployment target, include the profile: + +```shell +# For external-idp deployments (Keycloak, Google, etc.) +./mvnw package -Dnative -Dquarkus.profile=external-idp + +# For prod deployments (OpenShift OAuth) - default +./mvnw package -Dnative +``` + +**Important:** The CI/CD pipeline builds a universal image without a specific profile. Runtime profile selection via `QUARKUS_PROFILE` works for most configurations, but build-time properties (like `@IfBuildProfile` annotations) are fixed at compile time. + If you want to learn more about building native executables, please consult . ## Related Guides diff --git a/scripts/test-auth.sh b/scripts/test-auth.sh new file mode 100755 index 00000000..5dd6ea28 --- /dev/null +++ b/scripts/test-auth.sh @@ -0,0 +1,833 @@ +#!/bin/bash +# ============================================================================== +# Authentication Testing Script +# ============================================================================== +# This script automates testing of various authentication scenarios: +# - DevServices Keycloak (local development) +# - External Keycloak (standalone or with identity brokers) +# - Direct OIDC providers (Google) +# +# Usage: ./scripts/test-auth.sh [--help] +# +# For detailed documentation, see: docs/authentication.md +# ============================================================================== + +set -e + +# ============================================================================== +# CONFIGURATION - Customize these variables as needed +# ============================================================================== + +# Application settings +readonly APP_SERVICE_NAME="${APP_SERVICE_NAME:-exploit-iq-client}" +readonly APP_CLIENT_ID="${APP_CLIENT_ID:-exploit-iq-client}" +readonly APP_CLIENT_SECRET="${APP_CLIENT_SECRET:-example-credentials}" + +# Container engine (auto-detect podman or docker) +CONTAINER_ENGINE="${CONTAINER_ENGINE:-$(command -v podman >/dev/null 2>&1 && echo podman || echo docker)}" + +# Keycloak settings +readonly KC_REALM="${KC_REALM:-quarkus}" +readonly KC_LOCAL_PORT="${KC_LOCAL_PORT:-8190}" +readonly KC_IMAGE="${KC_IMAGE:-quay.io/keycloak/keycloak:26.4}" +readonly KC_ADMIN_USER="${KC_ADMIN_USER:-admin}" +readonly KC_ADMIN_PASS="${KC_ADMIN_PASS:-admin}" +readonly KC_OCP_NAMESPACE="${KC_OCP_NAMESPACE:-keycloak-dev}" +readonly KC_CONTAINER_NAME="${KC_CONTAINER_NAME:-keycloak-standalone}" + +# Local app settings +readonly LOCAL_APP_URL="${LOCAL_APP_URL:-http://localhost:8080}" + +# Test users +readonly TEST_USER_1="${TEST_USER_1:-bruce}" +readonly TEST_USER_1_PASS="${TEST_USER_1_PASS:-wayne}" +readonly TEST_USER_1_EMAIL="${TEST_USER_1_EMAIL:-bruce@wayne.com}" +readonly TEST_USER_2="${TEST_USER_2:-peter}" +readonly TEST_USER_2_PASS="${TEST_USER_2_PASS:-parker}" +readonly TEST_USER_2_EMAIL="${TEST_USER_2_EMAIL:-peter@parker.com}" + +# Timeouts +readonly HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}" +readonly ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-5m}" + +# ============================================================================== +# INTERNAL VARIABLES +# ============================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_NAMESPACE="" +KC_BASE_URL="" +KC_TOKEN="" +DEBUG="${DEBUG:-false}" + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' # No Color + +# ============================================================================== +# UTILITY FUNCTIONS +# ============================================================================== + +print_error() { + echo -e "${RED}Error: $1${NC}" >&2 +} + +print_success() { + echo -e "${GREEN}$1${NC}" +} + +print_warning() { + echo -e "${YELLOW}$1${NC}" +} + +print_info() { + echo "$1" +} + +print_debug() { + if [ "$DEBUG" = "true" ]; then + echo -e "${YELLOW}[DEBUG] $1${NC}" + fi +} + +show_help() { + cat << EOF +Authentication Testing Script + +Usage: $0 [OPTIONS] + +Options: + --help, -h Show this help message + --debug, -d Enable debug output (shows curl responses, URLs, etc.) + +Environment Variables: + APP_CLIENT_ID OIDC client ID (default: exploit-iq-client) + APP_CLIENT_SECRET OIDC client secret (default: example-credentials) + APP_SERVICE_NAME Kubernetes deployment name (default: exploit-iq-client) + CONTAINER_ENGINE Container runtime: podman or docker (auto-detected) + DEBUG Enable debug mode (true/false, default: false) + KC_ADMIN_PASS Keycloak admin password (default: admin) + KC_ADMIN_USER Keycloak admin username (default: admin) + KC_CONTAINER_NAME Keycloak container name (default: keycloak-standalone) + KC_IMAGE Keycloak container image (default: quay.io/keycloak/keycloak:26.4) + KC_LOCAL_PORT Local Keycloak port (default: 8190) + KC_OCP_NAMESPACE OpenShift namespace for Keycloak (default: keycloak-dev) + +Scenarios: + 1) DevServices Keycloak - Local development with auto-configured Keycloak + 2) DevServices + GitHub - Local Keycloak with GitHub identity broker + 3) External Keycloak - Standalone external Keycloak + 4) External Keycloak + GitHub - External Keycloak with GitHub broker + 5) External Keycloak + Google - External Keycloak with Google broker + 6) Direct Google OIDC - Direct connection to Google (no Keycloak) + +Test Users (for Keycloak scenarios): + - ${TEST_USER_1}/${TEST_USER_1_PASS} + - ${TEST_USER_2}/${TEST_USER_2_PASS} + +For more information, see: docs/authentication.md +EOF + exit 0 +} + +check_dependencies() { + local missing=() + + command -v curl >/dev/null 2>&1 || missing+=("curl") + + # Check for container engine + if ! command -v podman >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then + missing+=("podman or docker") + fi + + if [ ${#missing[@]} -ne 0 ]; then + print_error "Missing required dependencies: ${missing[*]}" + exit 1 + fi + + print_info "Using container engine: ${CONTAINER_ENGINE}" +} + +check_oc_dependency() { + if ! command -v oc >/dev/null 2>&1; then + print_error "OpenShift CLI (oc) is required for this scenario" + exit 1 + fi +} + +validate_not_empty() { + local value="$1" + local name="$2" + + if [ -z "$value" ]; then + print_error "$name cannot be empty" + exit 1 + fi +} + +# ============================================================================== +# NAMESPACE DETECTION +# ============================================================================== + +detect_ocp_namespace() { + check_oc_dependency + + local detected_ns + detected_ns=$(oc project -q 2>/dev/null || true) + + if [ -n "$detected_ns" ]; then + echo "" + print_info "Detected OpenShift Project/Namespace: '$detected_ns'" + read -p "Is this the correct namespace for the application? [Y/n]: " confirm_ns + confirm_ns=${confirm_ns:-Y} + + if [[ "$confirm_ns" =~ ^[Yy]$ ]]; then + APP_NAMESPACE="$detected_ns" + else + read -p "Enter target OpenShift Project/Namespace: " APP_NAMESPACE + fi + else + echo "" + print_warning "Could not detect current OpenShift Project." + read -p "Enter target OpenShift Project/Namespace: " APP_NAMESPACE + fi + + validate_not_empty "$APP_NAMESPACE" "Namespace" + print_info "Using Namespace: $APP_NAMESPACE" + echo "" +} + +# ============================================================================== +# KEYCLOAK SETUP FUNCTIONS +# ============================================================================== + +setup_local_keycloak() { + echo "" + print_info "--- Setting up Keycloak (Local Container) ---" + + # Clean up existing container + if ${CONTAINER_ENGINE} ps -a --format '{{.Names}}' | grep -q "^${KC_CONTAINER_NAME}$"; then + print_info "Removing existing Keycloak container..." + ${CONTAINER_ENGINE} rm -f "${KC_CONTAINER_NAME}" >/dev/null 2>&1 || true + fi + + print_info "Starting Keycloak container with ${CONTAINER_ENGINE}..." + ${CONTAINER_ENGINE} run -d --name "${KC_CONTAINER_NAME}" \ + -p "${KC_LOCAL_PORT}:8080" \ + -e "KEYCLOAK_ADMIN=${KC_ADMIN_USER}" \ + -e "KEYCLOAK_ADMIN_PASSWORD=${KC_ADMIN_PASS}" \ + -e KC_HEALTH_ENABLED=true \ + -e KC_METRICS_ENABLED=true \ + -e KC_HTTP_MANAGEMENT_HEALTH_ENABLED=false \ + -e KC_HOSTNAME=localhost \ + -e KC_HTTP_ENABLED=true \ + "${KC_IMAGE}" start-dev >/dev/null + + KC_BASE_URL="http://localhost:${KC_LOCAL_PORT}" + wait_for_keycloak_health "$KC_BASE_URL" + + # Disable SSL requirement for local development (Keycloak 26.x requires this) + print_info "Configuring Keycloak for HTTP access..." + ${CONTAINER_ENGINE} exec "${KC_CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://localhost:8080 --realm master --user "${KC_ADMIN_USER}" --password "${KC_ADMIN_PASS}" >/dev/null + ${CONTAINER_ENGINE} exec "${KC_CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE >/dev/null + print_success "Keycloak configured for HTTP" + echo "" +} + +setup_ocp_keycloak() { + check_oc_dependency + + echo "" + print_info "--- Setting up Keycloak (OpenShift) ---" + + # Create namespace if not exists + if ! oc get project "$KC_OCP_NAMESPACE" >/dev/null 2>&1; then + print_info "Creating namespace '$KC_OCP_NAMESPACE'..." + oc new-project "$KC_OCP_NAMESPACE" >/dev/null + fi + + # Apply Keycloak resources + print_info "Applying Keycloak resources..." + oc process -f https://raw.githubusercontent.com/keycloak/keycloak-quickstarts/refs/heads/main/openshift/keycloak.yaml \ + -p "KC_BOOTSTRAP_ADMIN_USERNAME=${KC_ADMIN_USER}" \ + -p "KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_ADMIN_PASS}" \ + -p "NAMESPACE=${KC_OCP_NAMESPACE}" \ + | oc apply -n "$KC_OCP_NAMESPACE" -f - >/dev/null + + # Inject Health settings + oc set env dc/keycloak -n "$KC_OCP_NAMESPACE" \ + KC_HEALTH_ENABLED=true \ + KC_METRICS_ENABLED=true \ + KC_HTTP_MANAGEMENT_HEALTH_ENABLED=false + + print_info "Waiting for rollout..." + oc rollout status dc/keycloak -n "$KC_OCP_NAMESPACE" --timeout="$ROLLOUT_TIMEOUT" >/dev/null + + # Detect Route + print_info "Detecting Route..." + local kc_route="" + for _ in {1..10}; do + kc_route=$(oc get route keycloak -n "$KC_OCP_NAMESPACE" -o jsonpath='{.spec.host}' 2>/dev/null || echo "") + if [ -n "$kc_route" ]; then break; fi + sleep 2 + done + + if [ -z "$kc_route" ]; then + print_error "Keycloak Route not found in namespace $KC_OCP_NAMESPACE" + exit 1 + fi + + KC_BASE_URL="https://$kc_route" + print_info "Keycloak URL: $KC_BASE_URL" + wait_for_keycloak_health "$KC_BASE_URL" +} + +wait_for_keycloak_health() { + local url="$1" + local health_url="${url}/health/ready" + + print_info "Waiting for Keycloak health check..." + local count=0 + while [ $count -lt "$HEALTH_CHECK_TIMEOUT" ]; do + if curl -k -s "$health_url" 2>/dev/null | grep -q '"status": "UP"'; then + print_success "Keycloak is ready" + return 0 + fi + echo -n "." + sleep 2 + count=$((count + 2)) + done + + echo "" + print_error "Keycloak health check timed out after ${HEALTH_CHECK_TIMEOUT}s" + exit 1 +} + +# ============================================================================== +# KEYCLOAK CONFIGURATION +# ============================================================================== + +get_admin_token() { + local token_url="${KC_BASE_URL}/realms/master/protocol/openid-connect/token" + print_debug "Token URL: $token_url" + print_debug "Admin user: ${KC_ADMIN_USER}" + + local response + response=$(curl -k -s -X POST "$token_url" \ + -d "username=${KC_ADMIN_USER}" \ + -d "password=${KC_ADMIN_PASS}" \ + -d "grant_type=password" \ + -d "client_id=admin-cli") + + print_debug "Token response: $response" + + KC_TOKEN=$(echo "$response" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4) + + if [ -z "$KC_TOKEN" ]; then + print_error "Failed to obtain Keycloak admin token" + print_error "Response: $response" + exit 1 + fi +} + +configure_keycloak() { + local app_url="$1" + + print_info "--- Configuring Keycloak ---" + get_admin_token + + # Delete existing realm for clean state + echo -n "Cleaning up existing realm... " + curl -k -s -X DELETE "${KC_BASE_URL}/admin/realms/${KC_REALM}" \ + -H "Authorization: Bearer $KC_TOKEN" || true + print_success "done" + + # Create realm (with sslRequired=NONE for local development) + echo -n "Creating realm '${KC_REALM}'... " + curl -k -s -o /dev/null -X POST "${KC_BASE_URL}/admin/realms" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"realm\": \"${KC_REALM}\", \"enabled\": true, \"sslRequired\": \"NONE\"}" + print_success "done" + + sleep 1 + + # Create client + echo -n "Creating client '${APP_CLIENT_ID}'... " + curl -k -s -o /dev/null -X POST "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"${APP_CLIENT_ID}\", + \"enabled\": true, + \"clientAuthenticatorType\": \"client-secret\", + \"secret\": \"${APP_CLIENT_SECRET}\", + \"redirectUris\": [\"${app_url}/*\"], + \"webOrigins\": [\"${app_url}\"], + \"publicClient\": false, + \"standardFlowEnabled\": true, + \"directAccessGrantsEnabled\": true, + \"attributes\": { + \"post.logout.redirect.uris\": \"${app_url}/logged-out\" + } + }" + print_success "done" + + # Create test users + echo -n "Creating test users... " + curl -k -s -o /dev/null -X POST "${KC_BASE_URL}/admin/realms/${KC_REALM}/users" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"${TEST_USER_1}\", + \"enabled\": true, + \"email\": \"${TEST_USER_1_EMAIL}\", + \"firstName\": \"${TEST_USER_1^}\", + \"lastName\": \"Wayne\", + \"emailVerified\": true, + \"credentials\": [{\"type\": \"password\", \"value\": \"${TEST_USER_1_PASS}\", \"temporary\": false}] + }" + + curl -k -s -o /dev/null -X POST "${KC_BASE_URL}/admin/realms/${KC_REALM}/users" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"${TEST_USER_2}\", + \"enabled\": true, + \"email\": \"${TEST_USER_2_EMAIL}\", + \"firstName\": \"${TEST_USER_2^}\", + \"lastName\": \"Parker\", + \"emailVerified\": true, + \"credentials\": [{\"type\": \"password\", \"value\": \"${TEST_USER_2_PASS}\", \"temporary\": false}] + }" + print_success "done" + + # Create protocol mappers + echo -n "Creating protocol mappers... " + local client_uuid + client_uuid=$(curl -k -s -H "Authorization: Bearer $KC_TOKEN" \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients?clientId=${APP_CLIENT_ID}" \ + | grep -o '"id":"[^"]*' | head -1 | cut -d'"' -f4) + + for mapper in "preferred_username:username" "upn:username" "email:email"; do + local name="${mapper%%:*}" + local attr="${mapper##*:}" + curl -k -s -o /dev/null -X POST \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients/${client_uuid}/protocol-mappers/models" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"${name}\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-usermodel-property-mapper\", + \"config\": { + \"userinfo.token.claim\": \"true\", + \"user.attribute\": \"${attr}\", + \"id.token.claim\": \"true\", + \"access.token.claim\": \"true\", + \"claim.name\": \"${name}\", + \"jsonType.label\": \"String\" + } + }" + done + print_success "done" + + print_success "Keycloak configured successfully" +} + +# ============================================================================== +# IDENTITY PROVIDER CONFIGURATION +# ============================================================================== + +configure_github_idp() { + local client_id="$1" + local client_secret="$2" + + validate_not_empty "$client_id" "GitHub Client ID" + validate_not_empty "$client_secret" "GitHub Client Secret" + + get_admin_token + + echo -n "Configuring GitHub Identity Provider... " + curl -k -s -o /dev/null -X POST "${KC_BASE_URL}/admin/realms/${KC_REALM}/identity-provider/instances" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"alias\": \"github\", + \"providerId\": \"github\", + \"enabled\": true, + \"config\": { + \"clientId\": \"${client_id}\", + \"clientSecret\": \"${client_secret}\", + \"defaultScope\": \"user:email\", + \"syncMode\": \"IMPORT\" + } + }" + print_success "done" + + # Add mappers + echo -n "Creating GitHub mappers... " + curl -k -s -o /dev/null -X POST \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/identity-provider/instances/github/mappers" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "github-username-mapper", + "identityProviderAlias": "github", + "identityProviderMapper": "github-user-attribute-mapper", + "config": { + "syncMode": "INHERIT", + "jsonField": "login", + "userAttribute": "username" + } + }' + + curl -k -s -o /dev/null -X POST \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/identity-provider/instances/github/mappers" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "github-email-mapper", + "identityProviderAlias": "github", + "identityProviderMapper": "github-user-attribute-mapper", + "config": { + "syncMode": "INHERIT", + "jsonField": "email", + "userAttribute": "email" + } + }' + print_success "done" +} + +configure_google_idp() { + local client_id="$1" + local client_secret="$2" + + validate_not_empty "$client_id" "Google Client ID" + validate_not_empty "$client_secret" "Google Client Secret" + + get_admin_token + + echo -n "Configuring Google Identity Provider... " + curl -k -s -o /dev/null -X POST "${KC_BASE_URL}/admin/realms/${KC_REALM}/identity-provider/instances" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"alias\": \"google\", + \"providerId\": \"google\", + \"enabled\": true, + \"config\": { + \"clientId\": \"${client_id}\", + \"clientSecret\": \"${client_secret}\", + \"defaultScope\": \"openid email profile\" + } + }" + print_success "done" + + # Add mappers + echo -n "Creating Google mappers... " + curl -k -s -o /dev/null -X POST \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/identity-provider/instances/google/mappers" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "google-email-mapper", + "identityProviderAlias": "google", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "email", + "user.attribute": "email" + } + }' + print_success "done" +} + +# ============================================================================== +# APPLICATION EXECUTION +# ============================================================================== + +start_local_quarkus() { + local extra_args=("$@") + + cd "$PROJECT_ROOT" + print_info "Starting Quarkus in dev mode..." + ./mvnw quarkus:dev "${extra_args[@]}" +} + +update_ocp_deployment() { + local env_vars=("$@") + + print_info "=== Updating Remote OCP Deployment ===" + print_info "Target: deployment/${APP_SERVICE_NAME} in ${APP_NAMESPACE}" + + oc set env "deployment/${APP_SERVICE_NAME}" -n "$APP_NAMESPACE" \ + "${env_vars[@]}" \ + --overwrite + + print_success "Environment updated. Restarting deployment..." + oc rollout restart "deployment/${APP_SERVICE_NAME}" -n "$APP_NAMESPACE" + oc rollout status "deployment/${APP_SERVICE_NAME}" -n "$APP_NAMESPACE" --timeout="$ROLLOUT_TIMEOUT" +} + +get_app_route() { + local route_host + route_host=$(oc get route "$APP_SERVICE_NAME" -n "$APP_NAMESPACE" -o jsonpath='{.spec.host}' 2>/dev/null || echo "") + + if [ -z "$route_host" ]; then + print_error "Could not find route '${APP_SERVICE_NAME}' in namespace '${APP_NAMESPACE}'" + print_info "Available routes:" + oc get routes -n "$APP_NAMESPACE" -o name + exit 1 + fi + + echo "https://${route_host}" +} + +# ============================================================================== +# SCENARIO HANDLERS +# ============================================================================== + +scenario_devservices() { + echo "" + print_info "=== Scenario 1: DevServices Keycloak ===" + print_info "Starting Quarkus with DevServices Keycloak..." + echo "" + print_info "Test users: ${TEST_USER_1}/${TEST_USER_1_PASS}, ${TEST_USER_2}/${TEST_USER_2_PASS}" + print_info "App URL: ${LOCAL_APP_URL}" + echo "" + + start_local_quarkus -Dquarkus.keycloak.devservices.enabled=true +} + +scenario_devservices_github() { + echo "" + print_info "=== Scenario 2: DevServices + GitHub Broker ===" + + setup_local_keycloak + + echo "" + print_warning "GitHub OAuth Callback URL:" + print_info " ${KC_BASE_URL}/realms/${KC_REALM}/broker/github/endpoint" + echo "" + + read -p "GitHub Client ID: " github_id + read -p "GitHub Client Secret: " github_secret + + configure_keycloak "$LOCAL_APP_URL" + configure_github_idp "$github_id" "$github_secret" + + echo "" + print_info "App URL: ${LOCAL_APP_URL}" + echo "" + + start_local_quarkus \ + -Dquarkus.profile=external-idp \ + "-Dquarkus.oidc.auth-server-url=${KC_BASE_URL}/realms/${KC_REALM}" \ + "-Dquarkus.oidc.credentials.secret=${APP_CLIENT_SECRET}" \ + -Dmorpheus.syncer.health.url=http://localhost:8088/exploit-iq/component-syncer +} + +scenario_external_keycloak() { + local broker_type="$1" # "none", "github", or "google" + + echo "" + print_info "Select Keycloak Environment:" + echo "1) Local Docker (localhost:${KC_LOCAL_PORT})" + echo "2) OpenShift (Auto-provision in '${KC_OCP_NAMESPACE}')" + read -p "Option: " infra_choice + + local app_base_url="" + local run_mode="" + + if [ "$infra_choice" = "2" ]; then + detect_ocp_namespace + setup_ocp_keycloak + + print_info "Detecting App Route in namespace '${APP_NAMESPACE}'..." + app_base_url=$(get_app_route 2>/dev/null || echo "") + + if [ -n "$app_base_url" ]; then + run_mode="remote" + print_success "Found OCP App: ${app_base_url}" + else + print_warning "App Route not found. Falling back to Local App." + app_base_url="$LOCAL_APP_URL" + run_mode="local" + fi + else + setup_local_keycloak + app_base_url="$LOCAL_APP_URL" + run_mode="local" + fi + + configure_keycloak "$app_base_url" + + # Configure broker if needed + if [ "$broker_type" = "github" ]; then + echo "" + print_warning "GitHub OAuth Callback URL:" + print_info " ${KC_BASE_URL}/realms/${KC_REALM}/broker/github/endpoint" + echo "" + read -p "GitHub Client ID: " broker_id + read -p "GitHub Client Secret: " broker_secret + configure_github_idp "$broker_id" "$broker_secret" + elif [ "$broker_type" = "google" ]; then + echo "" + print_warning "Google OAuth Callback URL:" + print_info " ${KC_BASE_URL}/realms/${KC_REALM}/broker/google/endpoint" + echo "" + read -p "Google Client ID: " broker_id + read -p "Google Client Secret: " broker_secret + configure_google_idp "$broker_id" "$broker_secret" + fi + + echo "" + if [ "$run_mode" = "remote" ]; then + # Keycloak mode: set auth-server-url, unset provider/client-id (use defaults) + update_ocp_deployment \ + "QUARKUS_PROFILE=external-idp" \ + "QUARKUS_OIDC_AUTH_SERVER_URL=${KC_BASE_URL}/realms/${KC_REALM}" \ + "QUARKUS_OIDC_CREDENTIALS_SECRET=${APP_CLIENT_SECRET}" \ + "QUARKUS_OIDC_TLS_VERIFICATION=none" \ + "QUARKUS_OIDC_PROVIDER-" \ + "QUARKUS_OIDC_CLIENT_ID-" + + print_success "Done. Check app at ${app_base_url}" + else + start_local_quarkus \ + -Dquarkus.profile=external-idp \ + "-Dquarkus.oidc.auth-server-url=${KC_BASE_URL}/realms/${KC_REALM}" \ + "-Dquarkus.oidc.credentials.secret=${APP_CLIENT_SECRET}" \ + -Dquarkus.keycloak.devservices.enabled=false \ + -Dquarkus.oidc.tls.verification=none \ + -Dmorpheus.syncer.health.url=http://localhost:8088/exploit-iq/component-syncer + fi +} + +scenario_direct_google() { + echo "" + print_info "=== Scenario 6: Direct Google OIDC ===" + echo "" + echo "Select Environment:" + echo "1) Local (localhost:8080)" + echo "2) OpenShift" + read -p "Option: " env_choice + + read -p "Google Client ID: " google_id + read -p "Google Client Secret: " google_secret + + validate_not_empty "$google_id" "Google Client ID" + validate_not_empty "$google_secret" "Google Client Secret" + + if [ "$env_choice" = "2" ]; then + detect_ocp_namespace + + local app_url + app_url=$(get_app_route) + + echo "" + print_warning "Add this EXACT URI to Google Cloud Console (Authorized redirect URIs):" + print_info " ${app_url}/" + echo "" + read -p "Press Enter when ready..." + + print_info "Updating Remote OCP Deployment for Direct Google..." + + # Direct Google mode: set provider/client-id, unset auth-server-url/tls + update_ocp_deployment \ + "QUARKUS_PROFILE=external-idp" \ + "QUARKUS_OIDC_PROVIDER=google" \ + "QUARKUS_OIDC_CLIENT_ID=${google_id}" \ + "QUARKUS_OIDC_CREDENTIALS_SECRET=${google_secret}" \ + "QUARKUS_OIDC_AUTH_SERVER_URL-" \ + "QUARKUS_OIDC_TLS_VERIFICATION-" + + print_success "Done. Check app at ${app_url}" + else + echo "" + print_warning "Add this URI to Google Cloud Console (Authorized redirect URIs):" + print_info " http://localhost:8080/" + echo "" + + start_local_quarkus \ + -Dquarkus.oidc.provider=google \ + "-Dquarkus.oidc.client-id=${google_id}" \ + "-Dquarkus.oidc.credentials.secret=${google_secret}" \ + -Dquarkus.keycloak.devservices.enabled=false + fi +} + +# ============================================================================== +# MAIN +# ============================================================================== + +main() { + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + ;; + --debug|-d) + DEBUG=true + shift + ;; + *) + shift + ;; + esac + done + + check_dependencies + cd "$PROJECT_ROOT" + + echo "==========================================" + echo " Authentication Test Automation" + echo "==========================================" + echo "" + echo "--- Local Only (no OCP) ---" + echo "1) DevServices Keycloak (Local Only)" + echo "2) DevServices + GitHub Broker (Local Only)" + echo "" + echo "--- External Keycloak (Local or OCP) ---" + echo "3) External Keycloak (Standard)" + echo "4) External Keycloak + GitHub Broker" + echo "5) External Keycloak + Google Broker" + echo "" + echo "--- Direct OIDC ---" + echo "6) Direct Google OIDC" + echo "" + read -p "Enter option: " choice + + case "$choice" in + 1) + scenario_devservices + ;; + 2) + scenario_devservices_github + ;; + 3) + scenario_external_keycloak "none" + ;; + 4) + scenario_external_keycloak "github" + ;; + 5) + scenario_external_keycloak "google" + ;; + 6) + scenario_direct_google + ;; + *) + print_error "Invalid option: $choice" + exit 1 + ;; + esac +} + +main "$@" diff --git a/src/main/docker/Dockerfile.multi-stage b/src/main/docker/Dockerfile.multi-stage index 7b976222..d8795332 100644 --- a/src/main/docker/Dockerfile.multi-stage +++ b/src/main/docker/Dockerfile.multi-stage @@ -1,6 +1,11 @@ ## Stage 1 : build with maven builder image with native capabilities FROM registry.redhat.io/quarkus/mandrel-for-jdk-21-rhel8:23.1 AS build +# Build profile - affects build-time properties only +# Default: prod (OpenShift OAuth). Override with --build-arg QUARKUS_PROFILE=external-idp +# Note: Runtime profile is set via QUARKUS_PROFILE env var at container startup +ARG QUARKUS_PROFILE=prod + COPY --chown=quarkus:quarkus mvnw /code/mvnw COPY --chown=quarkus:quarkus .mvn /code/.mvn COPY --chown=quarkus:quarkus pom.xml /code/ @@ -10,7 +15,7 @@ USER quarkus WORKDIR /code RUN ./mvnw -B -Pnative org.apache.maven.plugins:maven-dependency-plugin:3.6.1:go-offline COPY --chown=quarkus:quarkus src /code/src -RUN ./mvnw verify -B -Pnative -Dmaven.test.skip=true -Dquarkus.native.native-image-xmx=8g +RUN ./mvnw verify -B -Pnative -Dmaven.test.skip=true -Dquarkus.profile=${QUARKUS_PROFILE} -Dquarkus.native.native-image-xmx=8g RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /tmp diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/TokenResource.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/TokenResource.java index beb534a7..64b81d7c 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/TokenResource.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/TokenResource.java @@ -5,17 +5,12 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.time.Instant; -import java.util.Date; - import org.eclipse.microprofile.openapi.annotations.Operation; import com.redhat.ecosystemappeng.morpheus.service.UserService; -import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; -@SecurityRequirement(name = "jwt") @Path("/user") public class TokenResource { @@ -24,20 +19,57 @@ public class TokenResource { @GET @Produces("application/json") - @Operation(hidden=true) + @Operation(hidden = true) public String getUserName() { return String.format("{\"name\": \"%s\"}", userService.getUserName()); } + /** + * Performs a local logout using the standard 'Clear-Site-Data' header. + * This feature is available only in secure contexts (HTTPS) + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Clear-Site-Data + */ @POST @Path("/logout") - @Operation(hidden=true) + @Produces(MediaType.TEXT_HTML) + @Operation(hidden = true) public Response logout() { - final NewCookie removeCookie = new NewCookie.Builder("q_session") - .maxAge(0) - .expiry(Date.from(Instant.EPOCH)) - .path("/") + return Response.ok(LOGGED_OUT_HTML) + .header("Clear-Site-Data", "\"cookies\", \"storage\"") .build(); - return Response.noContent().cookie(removeCookie).build(); } + + private static final String LOGGED_OUT_HTML = """ + + + + + + Logged Out - ExploitIQ + + + +
+
+
+
+ +
+

Successfully Logged Out

+
+ You have been logged out from ExploitIQ. +
+ +
+
+
+ + + """; } diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/UserService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/UserService.java index 6526798c..ca342152 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/UserService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/UserService.java @@ -15,10 +15,17 @@ public class UserService { public String getUserName() { if(Objects.nonNull(userInfo)) { - var name = userInfo.getString("upn"); + // Try email first + var name = userInfo.getString("email"); if(Objects.nonNull(name)) { return name; } + // Fallback to upn + name = userInfo.getString("upn"); + if(Objects.nonNull(name)) { + return name; + } + // Fallback to metadata.name var metadata = userInfo.getObject("metadata"); if(Objects.nonNull(metadata)) { name = metadata.getString("name"); diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/jwt/JkwsRequestFilter.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/jwt/JkwsRequestFilter.java index 03df2bbc..6f71ee84 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/jwt/JkwsRequestFilter.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/jwt/JkwsRequestFilter.java @@ -8,22 +8,31 @@ import java.nio.file.Path; import java.util.Optional; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import io.quarkus.arc.Unremovable; import io.quarkus.oidc.common.OidcEndpoint; -import io.quarkus.oidc.common.OidcRequestFilter; import io.quarkus.oidc.common.OidcEndpoint.Type; +import io.quarkus.oidc.common.OidcRequestFilter; import io.vertx.core.http.HttpMethod; +/** + * OIDC request filter that adds ServiceAccount token to JWKS requests. + * Only active when discovery-enabled=false (prod profile with OpenShift OAuth). + * When using external-idp/dev profiles with discovery-enabled=true, this filter is skipped. + */ @ApplicationScoped @OidcEndpoint(value = Type.JWKS) @Unremovable public class JkwsRequestFilter implements OidcRequestFilter { private static final Logger LOGGER = Logger.getLogger(JkwsRequestFilter.class); - private static final Path SA_TOKEN_PATH = Path.of("/var/run/secrets/kubernetes.io/serviceaccount/token"); + + @ConfigProperty(name = "quarkus.oidc.discovery-enabled", defaultValue = "true") + boolean discoveryEnabled; + Optional token = Optional.empty(); @PostConstruct @@ -39,11 +48,19 @@ void loadToken() { @Override public void filter(OidcRequestContext requestContext) { + // Skip if OIDC discovery is enabled (external-idp/dev profiles) + // Only apply for prod profile where discovery-enabled=false + if (discoveryEnabled) { + LOGGER.debugf("JkwsRequestFilter: skipping (discovery-enabled=%s)", discoveryEnabled); + return; + } + HttpMethod method = requestContext.request().method(); String uri = requestContext.request().uri(); + LOGGER.debugf("JkwsRequestFilter: processing request %s %s", method, uri); if (method == HttpMethod.GET && uri.endsWith("/jwks") && token.isPresent()) { + LOGGER.debug("JkwsRequestFilter: adding SA token to JWKS request"); requestContext.request().bearerTokenAuthentication(token.get()); } } - -} \ No newline at end of file +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9f9ba8c3..3ea46a40 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,32 +33,70 @@ quarkus.native.resources.includes=includes.json,excludes.json,preProcessingTempl quarkus.http.limits.max-body-size=1G quarkus.rest-client.morpheus.read-timeout=300000 -# Use OAuth2 (disable OIDC features) +# ============================================================================ +# OIDC CONFIGURATION PROFILES +# ============================================================================ +# +# Profile: prod (default) +# Use case: OpenShift with internal OAuth2 +# Activation: QUARKUS_PROFILE=prod +# Required environment variables: OPENSHIFT_DOMAIN, OAUTH_CLIENT_SECRET +# +# Profile: external-idp +# Use cases: +# - OpenShift/Kubernetes with Keycloak (standalone or as Identity Broker) +# - Direct GitHub/Google OAuth (without Keycloak) +# Activation: QUARKUS_PROFILE=external-idp +# Required environment variables: +# For Keycloak: QUARKUS_OIDC_AUTH_SERVER_URL, QUARKUS_OIDC_CREDENTIALS_SECRET +# For Direct Google/GitHub: QUARKUS_OIDC_PROVIDER=google|github, +# QUARKUS_OIDC_CLIENT_ID, QUARKUS_OIDC_CREDENTIALS_SECRET +# +# Profile: dev +# Use case: Local development with Keycloak DevServices +# Activation: ./mvnw quarkus:dev +# Auto-configured: Keycloak DevServices +# +# ============================================================================ + +# Base OIDC Configuration quarkus.oidc.application-type=hybrid quarkus.oidc.authentication.id-token-required=false quarkus.oidc.cache-user-info-in-idtoken=true quarkus.oidc.token.verify-access-token-with-user-info=true quarkus.oidc.client-id=exploit-iq-client -# OAuth2 Endpoints (OpenShift-specific paths) -%prod.quarkus.oidc.authorization-path=/oauth/authorize + +# --- PROFILE: prod (OpenShift with internal OAuth2) --- +%prod.quarkus.oidc.allow-token-introspection-cache=false +%prod.quarkus.oidc.auth-server-url=https://oauth-openshift.apps.${OPENSHIFT_DOMAIN} %prod.quarkus.oidc.authentication.add-openid-scope=false %prod.quarkus.oidc.authentication.scopes=user:info,user:check-access -%prod.quarkus.oidc.auth-server-url=https://oauth-openshift.apps.${OPENSHIFT_DOMAIN} +%prod.quarkus.oidc.authorization-path=/oauth/authorize %prod.quarkus.oidc.credentials.secret=${OAUTH_CLIENT_SECRET} %prod.quarkus.oidc.discovery-enabled=false +%prod.quarkus.oidc.jwks-path=https://api.${OPENSHIFT_DOMAIN}:6443/openid/v1/jwks %prod.quarkus.oidc.token-path=/oauth/token +%prod.quarkus.oidc.token.allow-jwt-introspection=false +%prod.quarkus.oidc.token.require-jwt-introspection-only=false %prod.quarkus.oidc.user-info-path=https://api.${OPENSHIFT_DOMAIN}:6443/apis/user.openshift.io/v1/users/~ -%prod.quarkus.oidc.jwks-path=https://api.${OPENSHIFT_DOMAIN}:6443/openid/v1/jwks -quarkus.http.auth.permission.authenticated.paths=/* -quarkus.http.auth.permission.authenticated.policy=authenticated +# --- PROFILE: external-idp (External Identity Provider) --- +# Supports: Keycloak (standalone or broker), GitHub, Google, Auth0, etc. +%external-idp.quarkus.oidc.application-type=hybrid +%external-idp.quarkus.oidc.authentication.add-openid-scope=true +%external-idp.quarkus.oidc.authentication.id-token-required=true +%external-idp.quarkus.oidc.authentication.scopes=openid,profile,email +%external-idp.quarkus.oidc.discovery-enabled=true +%external-idp.quarkus.oidc.token.verify-access-token-with-user-info=false + +# Logout endpoint returns HTML directly, no public paths needed # Management proxy endpoints (no authentication required) quarkus.http.auth.permission.management.paths=/health,/health/* quarkus.http.auth.permission.management.policy=permit +# All other paths require authentication +quarkus.http.auth.permission.authenticated.paths=/* +quarkus.http.auth.permission.authenticated.policy=authenticated -%prod.quarkus.oidc.token.allow-jwt-introspection=false -%prod.quarkus.oidc.token.require-jwt-introspection-only=false -%prod.quarkus.oidc.allow-token-introspection-cache=false # Smallrye Health UI is disabled by default in production environment %prod.quarkus.smallrye-health.ui.enabled=false # quarkus.log.category."io.quarkus.oidc".level=DEBUG @@ -75,6 +113,7 @@ quarkus.http.auth.permission.management.policy=permit %dev.quarkus.http.auth.permission.callback.paths=${quarkus.rest.path}/reports %dev.quarkus.http.auth.permission.callback.policy=permit %dev.quarkus.keycloak.devservices.enabled=false + # Management interface quarkus.management.enabled=true quarkus.management.port=9000 @@ -98,8 +137,8 @@ morpheus.queue.timeout=2h # Component Syncer settings morpheus.syncer.timeout=2h +morpheus.syncer.health.url=http://job-sink.knative-eventing.svc.cluster.local/${NAMESPACE}/component-syncer %dev.morpheus.syncer.health.url=http://localhost:8088/${NAMESPACE:exploit-iq}/component-syncer -%prod.morpheus.syncer.health.url=http://job-sink.knative-eventing.svc.cluster.local/${NAMESPACE}/component-syncer morpheus.syncer.health.timeout=2000ms # Syft settings @@ -110,6 +149,6 @@ quarkus.rest-client.feedback-api.url=http://morpheus-feedback-api:5001 %dev.quarkus.rest-client.feedback-api.url=http://localhost:5001 # Engine settings +morpheus.engine.health.url=http://exploit-iq:8080/health %dev.morpheus.engine.health.url=http://localhost:26466/health -%prod.morpheus.engine.health.url=http://exploit-iq:8080/health morpheus.engine.health.timeout=2000ms diff --git a/src/main/webui/src/App.jsx b/src/main/webui/src/App.jsx index 2899a021..eeeba58b 100644 --- a/src/main/webui/src/App.jsx +++ b/src/main/webui/src/App.jsx @@ -81,7 +81,7 @@ export default function App() { }; const handleLogout = () => { - logoutUser().then(() => window.location.replace("/")); + logoutUser(); } const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); diff --git a/src/main/webui/src/services/UserClient.js b/src/main/webui/src/services/UserClient.js index 80b95873..fcf1a228 100644 --- a/src/main/webui/src/services/UserClient.js +++ b/src/main/webui/src/services/UserClient.js @@ -15,13 +15,11 @@ export const getUserName = async () => { export const logoutUser = async () => { - // delete the credential cookie, essentially killing the session - const removeCookie = `q_session=; Max-Age=0;path=/`; - document.cookie = removeCookie; - - const response = await fetch('/api/v1/user/logout', { method: 'POST' }); - if (!response.ok) { - throw new ClientRequestError(response.status, response.statusText); - } - -} \ No newline at end of file + // Local-only logout (no IdP session termination) + // Clears cookies/storage via Clear-Site-Data header and shows logout page + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/api/v1/user/logout'; + document.body.appendChild(form); + form.submit(); +}