This project provides:
- 2fa backend: This handles all communication related to keycloak
 - 2fa frontend: This provides a self-service and an admin page and allows to reset OTP tokens
 
The project includes a Docker Compose setup for local development and testing.
All necessary images can be built locally from the docker/ directory.
- Docker and Docker Compose installed
 - Git repository cloned locally
 
For all types of integration tests we need a keycloak realm which has a specific setup. The realm's export is part of this repository and is loaded by keycloak in the docker compose environment.
This section describes how to (re-)create the realm if necessary.
The steps in this sections are only required to be executed whenever we change the keycloak version and the realm we've exported under tests/integration/data/export/realm-export-with-user.json doesn't work well anymore.
Start the keycloak setup container:
docker compose up -d keycloak-setup
docker compose exec -it keycloak-setup bashStart keycloak within the setup container:
export PATH=/opt/keycloak/bin:$PATH
kc.sh start-dev- Login as 
admin(pw:admin) underlocalhost:8080 - Create realm: 
test-realm - Create client: 
2fa-helpdesk- Activate 
Direct Access Grants - Set "Valid redirect URIs": 
* - Set "Valid post logout redirect URIs": 
* - Set "Web origins": 
* 
 - Activate 
 - Create groups:
2fa-users2FA Admins
 - Create "Client Scope": 
twofa-default- Create and add new mapper: 
2fa Groups- Set "Claim Name": 
2fa_user_groups - Turn off the "Full group path" option
 
 - Set "Claim Name": 
 
 - Create and add new mapper: 
 - Add client scope 
twofa-defaultto client2fa-helpdesk - Adjust "Authentication Flow":
- Deactivate OTP for 
direct grant - Change 
browser/Conditional OTPto required. 
 - Deactivate OTP for 
 - Create user with username 
test- Add user to groups:
2fa-users2FA Admins
 - Create non-temporary password: 
123qwe 
 - Add user to groups:
 
Stop keycloak the keycloak setup container and export the realm (by Ctrl+C). Then export the realms to JSON:
kc.sh export --file=/opt/keycloak/data/export/realm-export.json --optimizedExit and shutdown the container:
docker compose down --timeout 0 keycloak-setupRemove the admin user from the export to avoid failures on import:
jq '.[].users |= map(select(.realmRoles | index("admin") | not))' \
   tests/integration/data/export/realm-export.json \
   > tests/integration/data/export/realm-export-with-user.json
rm tests/integration/data/export/realm-export.jsonThe Docker Compose configuration supports different profiles for different use cases:
To run the complete 2FA helpdesk stack with all services:
# Build all images and start the integration test environment
docker compose --profile test-it up --build
# This will start:
# - Keycloak server (localhost:8080)
# - 2FA Helpdesk Backend API (localhost:8081)# (Re-)build and run the testrunner for testing tests/integration/api-2fa
docker compose run -it --rm --build testrunner
# Alternatively: (Re-)build and run the testrunner for testing tests/integration/api-2fa
docker compose run -it --rm --build testrunner pytest -vv tests/integration/api-2fa
# Run tests from above without explicit building
docker compose run -it --rm testrunner
docker compose run -it --rm testrunner pytest -vv tests/integration/api-2fa
In case you want to build and use images named the same as the one specified in the compose file:
# Build the testrunner image
docker compose --profile test-it up --build --no-start
docker compose --profile test-it down --remove-orphans --volumes
# Run the integration tests
docker compose run -it --rm testrunner pytest -vv tests/integration/api-2faPrerequisites:
- Python 3.13
 
First load the pipenv environment:
cd docker/testrunner
pipenv sync
pipenv shell
cd -The execute the tests:
pytest -vv tests/integration/api-2faTo run the complete 2FA helpdesk stack with all services, to build all images and to start the e2e test environment for testing headless via docker compose:
docker compose --profile test-e2e-service up --build
# This will start:
# - Keycloak server (keycloak:8080, localhost:8080)
# - 2FA Helpdesk Backend API (api:8080, localhost:8081)
# - 2FA Helpdesk Frontend (keycloak:80)In the scenario above keycloak and frontend share the same network (similar to a sidecar container in k8s).
The reason is, that otherwise keycloak won't make the login page available and raise a "Web Crypto API is not available" error in the browser.
That's a security feature, which enforces that pure http requests for login are only supported from localhost.
Thus the e2e tests wouldn't run properly.
(see also keycloak/keycloak#36804)
To run the complete 2FA helpdesk stack with all services, to build all images and start the e2e test environment for testing directly via pytest:
docker compose --profile test-e2e-local up --build
# This will start:
# - Keycloak server (keycloak:8080, localhost:8080)
# - 2FA Helpdesk Backend API (api:8080, localhost:8081)
# - 2FA Helpdesk Frontend (frontend:80, localhost:3000)This allows also to test in headed mode which is helpful during development.
Ensure all services are started with the profile test-e2e-service (see section on E2E setup).
# (Re-)build and run the testrunner for testing tests/integration/api-2fa
docker compose run -it --rm --build testrunner
# Alternatively: (Re-)build and run the testrunner for testing tests/integration/api-2fa
docker compose run -it --rm --build testrunner pytest -vv tests/integration/e2e
# Run tests from above without explicit building
docker compose run -it --rm testrunner
docker compose run -it --rm testrunner pytest -vv tests/integration/e2e
In case you want to build and use an image named the same as the one specified in the compose file:
# Build the testrunner image
docker compose --profile test-e2e up --build --no-start
docker compose --profile test-e2e down --remove-orphans --volumes
# Run the integration tests
docker compose run -it --rm testrunner pytest -vv tests/integration/e2ePrerequisites:
- Python 3.13
 
First load the pipenv environment:
cd docker/testrunner
pipenv sync
pipenv shell
cd -The execute the tests:
pytest -vv tests/integration/e2e# Run the test suite
docker compose run -it --rm test-chart
# Deal with trouble via pdb
docker compose run -it --rm test-chart tests/chart --pdb
# Have a shell
docker compose run -it --rm test-chart bash
pytest tests/chartWhen running the full stack (either with profile test-it or test-e2e-services):
- Keycloak Admin Console: http://localhost:8080/admin (username: admin & password:admin)
 - Backend API: Available internally to other services or access under http://localhost:8081
 
When running the full stack (with profile test-e2e-local):
- Keycloak Admin Console: http://localhost:8080/admin (username: admin & password:admin)
 - Backend API: Available internally to other services or access under http://localhost:8081
 - Frontend: Access under:
- Self-Service: http://localhost:3000/univention/2fa/self-service
 - Admin Page: http://localhost:3000/univention/2fa/admin
 - User credentials: User 
testwith password123qwe 
 
To force rebuild all images:
docker compose --profile test-e2e build --no-cacheTo rebuild a specific service:
docker compose build --no-cache apiCode samples
POST /token/reset/own/
Reset Own Token
Example responses
200 Response
{
  "users": [
    {
      "keycloak_internal_id": "string",
      "username": "string",
      "email": "string",
      "firstname": "string",
      "lastname": "string"
    }
  ],
  "succes": true,
  "detail": "string"
}| Status | Meaning | Description | Schema | 
|---|---|---|---|
| 200 | OK | Successful Response | ListUserResponse | 
Code samples
POST /token/reset/user/
Reset User Tokens
Body parameter
{
  "user_ids": [
    "string"
  ]
}| Name | In | Type | Required | Description | 
|---|---|---|---|---|
| body | body | ResetUsersRequest | true | none | 
Example responses
200 Response
{
  "success": true,
  "detail": "string",
  "resets_by_user": ""
}| Status | Meaning | Description | Schema | 
|---|---|---|---|
| 200 | OK | Successful Response | ResetResponse | 
| 422 | Unprocessable Entity | Validation Error | HTTPValidationError | 
Code samples
POST /list_users
List Users
Body parameter
{
  "query": ""
}| Name | In | Type | Required | Description | 
|---|---|---|---|---|
| body | body | ListUserQuery | false | none | 
Example responses
200 Response
{
  "users": [
    {
      "keycloak_internal_id": "string",
      "username": "string",
      "email": "string",
      "firstname": "string",
      "lastname": "string"
    }
  ],
  "succes": true,
  "detail": "string"
}| Status | Meaning | Description | Schema | 
|---|---|---|---|
| 200 | OK | Successful Response | ListUserResponse | 
| 422 | Unprocessable Entity | Validation Error | HTTPValidationError | 
Code samples
GET /whoami
Whoami
Example responses
200 Response
{
  "token": {},
  "success": true,
  "twofa_admin": true
}| Status | Meaning | Description | Schema | 
|---|---|---|---|
| 200 | OK | Successful Response | WhoAmIResponse | 
{
  "detail": [
    {
      "loc": [
        "string"
      ],
      "msg": "string",
      "type": "string"
    }
  ]
}
HTTPValidationError
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| detail | [ValidationError] | false | none | none | 
{
  "query": ""
}
ListUserQuery
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| query | any | false | none | Search for users matching this query | 
anyOf
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | string | false | none | none | 
or
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | null | false | none | none | 
{
  "users": [
    {
      "keycloak_internal_id": "string",
      "username": "string",
      "email": "string",
      "firstname": "string",
      "lastname": "string"
    }
  ],
  "succes": true,
  "detail": "string"
}
ListUserResponse
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| users | [User] | true | none | none | 
| succes | boolean | true | none | none | 
| detail | string | true | none | none | 
{
  "success": true,
  "detail": "string",
  "resets_by_user": ""
}
ResetResponse
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| success | boolean | true | none | none | 
| detail | string | true | none | none | 
| resets_by_user | object | false | none | Map of usernames to reset counts | 
| » additionalProperties | integer | false | none | none | 
{
  "user_ids": [
    "string"
  ]
}
ResetUsersRequest
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| user_ids | [string] | true | none | none | 
{
  "keycloak_internal_id": "string",
  "username": "string",
  "email": "string",
  "firstname": "string",
  "lastname": "string"
}
User
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| keycloak_internal_id | string | true | none | none | 
| username | string | true | none | none | 
| any | false | none | none | 
anyOf
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | string | false | none | none | 
or
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | null | false | none | none | 
continued
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| firstname | any | false | none | none | 
anyOf
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | string | false | none | none | 
or
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | null | false | none | none | 
continued
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| lastname | any | false | none | none | 
anyOf
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | string | false | none | none | 
or
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | null | false | none | none | 
{
  "loc": [
    "string"
  ],
  "msg": "string",
  "type": "string"
}
ValidationError
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| loc | [anyOf] | true | none | none | 
anyOf
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | string | false | none | none | 
or
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| » anonymous | integer | false | none | none | 
continued
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| msg | string | true | none | none | 
| type | string | true | none | none | 
{
  "token": {},
  "success": true,
  "twofa_admin": true
}
WhoAmIResponse
| Name | Type | Required | Restrictions | Description | 
|---|---|---|---|---|
| token | object | true | none | none | 
| success | boolean | true | none | none | 
| twofa_admin | boolean | true | none | none |