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-alicecollide: the secondPOSTanswers409. A random suffix makes every name unique. - Delete by name.
DELETE /api/runtimeremoves 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, and404for 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/runtimeis for when nothing else is running. - Deleting an identity stops new logins, not the tokens it already has.