Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Run a real OIDC provider in CI with GitHub Actions

Integration tests for an app that logs users in need an identity provider in the pipeline. A shared cloud tenant makes every job depend on the network and on state other jobs leave behind; mocking the provider tests your mock. NanoIDP runs inside the job, as a Python process or a service container, issues real signed tokens, and lets each test create the users it needs and remove them afterwards.

This page covers the test environment: starting the instance, knowing it is ready, an identity per test, isolation between tests and jobs, and cleanup. For the login flow itself, see Test an SPA login with PKCE.

Every file on this page is in examples/ci-github-actions/, and the repository's own CI runs it on every change, both ways shown here. Copy it into your repository with the same layout, which is the one the workflows below expect:

.github/workflows/tests.yml   one of the two workflows below
ci/start-nanoidp.sh           start and wait (step 2)
tests/conftest.py             the test_user fixture (step 3)
tests/test_identities.py      tests that use it (step 5)

1. Start NanoIDP in the job

Either way, pin the version: the IdP your tests run against should change when you decide, not when a release comes out.

As a Python process (any runner with Python, and the same script works on a laptop):

# .github/workflows/tests.yml - NanoIDP started from PyPI inside the job
name: tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install nanoidp==3.3.0 pytest pytest-xdist requests
      - run: bash ci/start-nanoidp.sh
      - run: pytest -n 4 tests
      - if: failure()
        run: cat nanoidp.log

As a service container, from the published image:

# .github/workflows/tests.yml - NanoIDP as a service container
name: tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    services:
      nanoidp:
        image: ghcr.io/cdelmonte-zg/nanoidp:v3.3.0
        ports:
          - 8000:8000
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pytest pytest-xdist requests pyjwt
      - name: Wait for NanoIDP
        run: timeout 30 sh -c 'until curl -fsS --max-time 2 http://localhost:8000/api/health; do sleep 1; done'
      - run: pytest -n 4 tests

The image starts with its default configuration: the demo-client / demo-secret client, the admin user, and the issuer http://localhost:8000, which is the address the job's steps reach it at. Tests that need more users or clients create them at runtime (step 3), so no configuration file has to be mounted into the container.

2. Ready means your instance answered

/api/health answers {"status":"ok"} once the server is up. Waiting for it is not enough on its own: if something else already listens on the port, the new process fails to bind and exits, and the health check is answered by the other process. The tests then run against an IdP that is not the one the job configured. The start script refuses a port that is already taken, even by a process that accepts connections and never answers, stops waiting as soon as its own process exits, and bounds every request in time:

#!/usr/bin/env bash
# Start a NanoIDP for this CI job and wait until it answers.
set -euo pipefail

# Refuse a port another process holds: the readiness check below would
# otherwise be answered by that process instead of this job's IdP. Only
# "connection refused" (curl exit code 7) means the port is free; anything
# else, a listener that accepts and never answers included, means it is not.
probe=0
curl -s -o /dev/null --max-time 2 http://localhost:8000/ || probe=$?
if [ "$probe" -ne 7 ]; then
  echo "port 8000 is already in use" >&2
  exit 1
fi

python -m nanoidp init ./idp-config
python -m nanoidp --config ./idp-config > nanoidp.log 2>&1 &
echo $! > nanoidp.pid

# Ready when /api/health answers; give up if the process exits or after 30 s
timeout 30 sh -c 'until curl -fsS --max-time 2 http://localhost:8000/api/health; do
  kill -0 "$(cat nanoidp.pid)" || exit 1; sleep 1; done' \
  || { cat nanoidp.log; exit 1; }

On a fresh hosted runner the port is free; on a self-hosted runner or a laptop, an IdP left over from an earlier run is exactly what the first check catches.

3. One identity per test

/api/runtime creates users and clients on the running instance, without touching its configuration files, and they work on every protocol surface at once. A pytest fixture gives each test a user of its own:

import os
import uuid

import pytest
import requests

IDP = os.environ.get("NANOIDP_URL", "http://localhost:8000")
TIMEOUT = 10  # seconds: a request to a stuck IdP fails instead of hanging


@pytest.fixture
def test_user():
    """A user that exists for one test only, under a name no other test uses."""
    user = {
        "username": f"ci-{uuid.uuid4().hex[:12]}",
        "password": uuid.uuid4().hex,
        "roles": ["TESTER"],
    }
    created = requests.post(f"{IDP}/api/runtime/users", json=user, timeout=TIMEOUT)
    assert created.status_code == 201, created.text
    yield user
    # Delete this user by name, never with DELETE /api/runtime: that removes
    # every runtime identity, including those of tests running in parallel
    deleted = requests.delete(f"{IDP}/api/runtime/users/{user['username']}",
                              timeout=TIMEOUT)
    # 404: the test deleted the user itself, as two tests here do on purpose
    assert deleted.status_code in (200, 404), deleted.text


def password_token(user, **extra):
    """A token for the user, from the client every default config declares."""
    return requests.post(f"{IDP}/token", auth=("demo-client", "demo-secret"), data={
        "grant_type": "password",
        "username": user["username"], "password": user["password"], **extra,
    }, timeout=TIMEOUT)

The body of POST /api/runtime/users is a users.yaml entry plus the username; clients work the same way through /api/runtime/clients. The full API is in Disposable test identities.

4. Isolation

Between jobs, there is nothing to do: each job starts its own instance on its own runner, and runtime identities live in that process's memory.

Between tests in one job is where the care goes, because parallel workers (pytest -n 4) share one instance:

  • Unique names. Two tests creating ci-alice collide: the second POST answers 409. A random suffix makes every name unique.
  • Delete by name. DELETE /api/runtime removes every runtime identity, including those of tests still running on other workers. With it in the teardown, a run of eight parallel tests that each sleep briefly before logging in loses one or two of them to a user deleted underneath; deleting by name, the same run passes every time.
  • Check the delete. A teardown that ignores the answer lets an identity survive a failed delete while the test still passes. The fixture accepts 200, and 404 for a user the test deleted itself; anything else fails the test.

5. Cleanup, and what deleting does not do

The fixture's teardown deletes its user, and the instance itself ends with the job, taking every runtime identity with it.

Deleting a user does not revoke what it was issued. An access token taken before the delete stays valid until it expires (introspection still says active: true); only its refresh token is refused. A test that asserts "the user is gone" has to check a new login, not a token it already holds. The example's tests pin both behaviours:

import jwt
import pytest
import requests
from conftest import IDP, TIMEOUT, password_token


@pytest.mark.parametrize("attempt", range(8))
def test_each_test_gets_its_own_user(test_user, attempt):
    response = password_token(test_user)
    assert response.status_code == 200, response.text
    claims = jwt.decode(response.json()["access_token"],
                        options={"verify_signature": False})
    assert claims["sub"] == test_user["username"]
    assert claims["roles"] == ["TESTER"]


def test_a_deleted_user_cannot_log_in(test_user):
    requests.delete(f"{IDP}/api/runtime/users/{test_user['username']}", timeout=TIMEOUT)
    assert password_token(test_user).json()["error"] == "invalid_grant"


def test_deleting_a_user_does_not_revoke_its_tokens(test_user):
    tokens = password_token(test_user, scope="openid offline_access").json()
    requests.delete(f"{IDP}/api/runtime/users/{test_user['username']}", timeout=TIMEOUT)

    # The access token taken before the delete is still valid until it expires
    introspection = requests.post(f"{IDP}/introspect", auth=("demo-client", "demo-secret"),
                                  data={"token": tokens["access_token"]}, timeout=TIMEOUT).json()
    assert introspection["active"] is True
    # The refresh token is refused
    refreshed = requests.post(f"{IDP}/token", auth=("demo-client", "demo-secret"), data={
        "grant_type": "refresh_token", "refresh_token": tokens["refresh_token"],
    }, timeout=TIMEOUT)
    assert refreshed.json()["error"] == "invalid_grant"

Access to the management API

/api/runtime, like the rest of /api/*, is open by default: fine for an instance on the job's loopback or a service container on the job's own network. When the IdP is reachable from elsewhere, set a management_secret and send it as X-Management-Secret on the POST and DELETE calls.

Takeaways

  • Run the IdP inside the job and pin its version; a pip process and a service container both work, and neither needs a config file mounted.
  • A health check proves that something answers on the port. Make sure it is the process the job started.
  • Give each test its own identity with a unique name, and delete it by name. DELETE /api/runtime is for when nothing else is running.
  • Deleting an identity stops new logins, not the tokens it already has.