Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/keycloak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: LocalStack Keycloak Extension Tests

on:
push:
paths:
- keycloak/**
branches:
- main
pull_request:
paths:
- .github/workflows/keycloak.yml
- keycloak/**
workflow_dispatch:

env:
LOCALSTACK_DISABLE_EVENTS: "1"
LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}

jobs:
integration-tests:
name: Run Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup LocalStack and extension
run: |
cd keycloak

docker pull localstack/localstack-pro &
docker pull quay.io/keycloak/keycloak:26.0 &
pip install localstack

make install
make dist
localstack extensions -v install file://$(ls ./dist/localstack_extension_keycloak-*.tar.gz)

DEBUG=1 localstack start -d
localstack wait

- name: Run integration tests
run: |
cd keycloak
make test

- name: Print logs
if: always()
run: |
localstack logs
localstack stop
11 changes: 11 additions & 0 deletions keycloak/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.venv/
*.egg-info/
dist/
build/
__pycache__/
*.pyc
.eggs/
.pytest_cache/
.ruff_cache/
cdk.out/
plan.md
74 changes: 74 additions & 0 deletions keycloak/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
VENV_BIN = python3 -m venv
VENV_DIR ?= .venv
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
VENV_RUN = . $(VENV_ACTIVATE)
TEST_PATH ?= tests

usage: ## Shows usage for this Makefile
@cat Makefile | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'

venv: $(VENV_ACTIVATE)

$(VENV_ACTIVATE): pyproject.toml
test -d .venv || $(VENV_BIN) .venv
$(VENV_RUN); pip install --upgrade pip setuptools plux
$(VENV_RUN); pip install -e .[dev]
touch $(VENV_DIR)/bin/activate

clean:
rm -rf .venv/
rm -rf build/
rm -rf .eggs/
rm -rf *.egg-info/

install: venv ## Install dependencies
$(VENV_RUN); python -m plux entrypoints

dist: venv ## Create distribution
$(VENV_RUN); python -m build

publish: clean-dist venv dist ## Publish extension to pypi
$(VENV_RUN); pip install --upgrade twine; twine upload dist/*

entrypoints: venv ## Generate plugin entrypoints for Python package
$(VENV_RUN); python -m plux entrypoints

format: ## Run ruff to format the codebase
$(VENV_RUN); python -m ruff format .; make lint

lint: ## Run ruff to lint the codebase
$(VENV_RUN); python -m ruff check --output-format=full .

test: ## Run integration tests (requires LocalStack running with the Extension installed)
$(VENV_RUN); pytest $(PYTEST_ARGS) $(TEST_PATH)

deploy: ## Deploy the sample app CDK stack
cd sample-app/cdk && pip install -r requirements.txt && cdklocal bootstrap && cdklocal deploy --all --require-approval never

test-sample: ## Run sample app API tests (requires LocalStack running with deployed stack)
@echo "Getting token from Keycloak..."
@TOKEN=$$(curl -s -X POST \
"http://keycloak.localhost.localstack.cloud:4566/realms/localstack/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=localstack-client" \
-d "client_secret=localstack-client-secret" | jq -r '.access_token') && \
API_ID=$$(awslocal apigateway get-rest-apis --query 'items[0].id' --output text) && \
API_URL="http://localhost:4566/_aws/execute-api/$${API_ID}/prod" && \
echo "API URL: $${API_URL}" && \
echo "\n=== List users ===" && \
curl -s -H "Authorization: Bearer $${TOKEN}" "$${API_URL}/users" | jq . && \
echo "\n=== Create user ===" && \
curl -s -X POST "$${API_URL}/users" \
-H "Authorization: Bearer $${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "email": "test@example.com", "name": "Test User"}' | jq . && \
echo "\n=== Get user ===" && \
curl -s -H "Authorization: Bearer $${TOKEN}" "$${API_URL}/users/testuser" | jq . && \
echo "\n=== Delete user ===" && \
curl -s -X DELETE -H "Authorization: Bearer $${TOKEN}" "$${API_URL}/users/testuser" | jq . && \
echo "\n=== Sample app tests completed ==="

clean-dist: clean
rm -rf dist/

.PHONY: clean clean-dist dist install publish usage venv format test lint entrypoints deploy test-sample
199 changes: 199 additions & 0 deletions keycloak/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Keycloak on LocalStack

This repo contains a [LocalStack Extension](https://github.com/localstack/localstack-extensions) that runs [Keycloak](https://www.keycloak.org/) alongside LocalStack for identity and access management with local AWS applications.

This Extension:

- Spins up a Keycloak instance on LocalStack startup.
- Auto-registers Keycloak as an OIDC identity provider in LocalStack IAM.
- Ships with a default realm (`localstack`) ready for OAuth2/OIDC flows.
- Exchanges Keycloak JWTs for temporary AWS credentials via `AssumeRoleWithWebIdentity`.

## Prerequisites

- Docker
- LocalStack Pro
- `localstack` CLI
- `make`

## Installation

```bash
localstack extensions install "git+https://github.com/localstack/localstack-extensions.git#egg=localstack-keycloak&subdirectory=keycloak"
```

## Install local development version

To install the extension into LocalStack in developer mode, you will need Python 3.11, and create a virtual environment in the extensions project.

```bash
make install
```

Then, to enable the extension for LocalStack, run

```bash
localstack extensions dev enable .
```

You can then start LocalStack with `EXTENSION_DEV_MODE=1` to load all enabled extensions:

```bash
EXTENSION_DEV_MODE=1 localstack start
```

## Usage

Start LocalStack:

```bash
localstack start
```

Keycloak will be available at:

| Endpoint | URL |
|----------|-----|
| Admin Console | http://localhost:8080/admin |
| Token Endpoint | http://keycloak.localhost.localstack.cloud:4566/realms/localstack/protocol/openid-connect/token |
| JWKS URL | http://keycloak.localhost.localstack.cloud:4566/realms/localstack/protocol/openid-connect/certs |

Keycloak ports are exposed directly on the host for easy access:

- **Admin Console & HTTP (8080)**: `http://localhost:8080` - Use this for the admin UI and direct API access
- **Management (9000)**: `http://localhost:9000` - Health and metrics endpoints (Keycloak 26+)

The gateway URL (`keycloak.localhost.localstack.cloud:4566`) is available for token endpoints and OIDC flows.

- **Default Admin Credentials**: `admin` / `admin`
- **Health check**: `curl http://localhost:9000/health/ready`

### Get an Access Token

```bash
TOKEN=$(curl -s -X POST \
"http://keycloak.localhost.localstack.cloud:4566/realms/localstack/protocol/openid-connect/token" \
-d "grant_type=client_credentials" \
-d "client_id=localstack-client" \
-d "client_secret=localstack-client-secret" | jq -r '.access_token')
```

### Exchange Token for AWS Credentials

```bash
# Create IAM role that trusts Keycloak
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::000000000000:oidc-provider/keycloak.localhost.localstack.cloud:4566/realms/localstack"
},
"Action": "sts:AssumeRoleWithWebIdentity"
}]
}
EOF

awslocal iam create-role \
--role-name KeycloakAuthRole \
--assume-role-policy-document file://trust-policy.json

# Exchange Keycloak token for AWS credentials
awslocal sts assume-role-with-web-identity \
--role-arn arn:aws:iam::000000000000:role/KeycloakAuthRole \
--role-session-name test-session \
--web-identity-token "$TOKEN"
```

## Configuration

| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `KEYCLOAK_REALM` | `localstack` | Name of the default realm |
| `KEYCLOAK_VERSION` | `26.0` | Keycloak Docker image version |
| `KEYCLOAK_REALM_FILE` | - | Path to custom realm JSON file |
| `KEYCLOAK_DEFAULT_USER` | - | Username for auto-created test user |
| `KEYCLOAK_DEFAULT_PASSWORD` | - | Password for auto-created test user |
| `KEYCLOAK_OIDC_AUDIENCE` | `localstack-client` | Audience claim for OIDC provider |
| `KEYCLOAK_FLAGS` | - | Additional flags for Keycloak start command |

> **Note**: When using `localstack start`, prefix environment variables with `LOCALSTACK_` (e.g., `LOCALSTACK_KEYCLOAK_REALM`).

### Custom Realm Configuration

Use your own realm JSON file with pre-configured users, roles, and clients.

```bash
# The path must be an absolute HOST path for Docker mount
# Use LOCALSTACK_ prefix when running via CLI
LOCALSTACK_KEYCLOAK_REALM_FILE=/path/to/my-realm.json localstack start
```

See [`quickstart/sample-realm.json`](quickstart/sample-realm.json) for a realm template and [`quickstart/README.md`](quickstart/README.md) for a step-by-step guide.

### Create Test Users

```bash
# Auto-create a test user on startup (use LOCALSTACK_ prefix with CLI)
LOCALSTACK_KEYCLOAK_DEFAULT_USER=testuser LOCALSTACK_KEYCLOAK_DEFAULT_PASSWORD=password123 localstack start
```

## Default Client

The extension creates a default client `localstack-client` with:

- **Client Secret**: `localstack-client-secret`
- **Flows**: Authorization Code, Client Credentials, Direct Access Grants
- **Service Account Roles**: `admin`, `user`

The service account for `localstack-client` is automatically assigned the `admin` realm role, enabling full access when using client credentials flow.

## Sample Application

See the `sample-app/` directory for a complete example demonstrating:

- API Gateway with Lambda Authorizer
- JWT validation with Keycloak
- Role-based access control
- DynamoDB user management

## Troubleshooting

### Keycloak takes a long time to start

Keycloak typically takes 30-60 seconds to fully start. The extension waits for the health check to pass before marking LocalStack as ready.

### Health check endpoint returns 404

In Keycloak 26+, the health endpoint is on port 9000:

```bash
curl http://localhost:9000/health/ready
```

### View Keycloak logs

```bash
docker logs ls-ext-keycloak
```

## Development

```bash
# Install dependencies
make install

# Run tests (requires LocalStack with extension running)
make test

# Format code
make format

# Lint
make lint
```

## License

Apache License 2.0
1 change: 1 addition & 0 deletions keycloak/localstack_keycloak/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
name = "localstack_keycloak"
Loading