One sign-in's audit trail, read through the admin API
The sections above read audit_events with psql, one door at a time.
This one drives a whole sign-in — a login, the code's redemption, a
refresh, a logout — and reads what it left through
GET /admin/tenants/{tenant}/audit, which is how an operator reads it.
$ADMIN_TOKEN is an admin access token got the way
the admin paths shows, here for a
second system administrator seeded on the same stack. event_type picks
the kind of row, and from narrows both queries to rows written since the
run began, request_id not being a filter. from is only as precise as the
second $SINCE names, so it scopes to this run only on a stack with no
other traffic in demo; the request id on each row is what shows that held
here:
BASE=http://localhost:3082/tenants/demo/protocol/openid-connect
LOGIN=http://localhost:3082/tenants/demo/login-actions/authenticate
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 | tr '+/' '-_' | tr -d '=')
AUTH_SESSION_ID=$(curl -sS --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid' \
--data-urlencode 'state=xyz-trail' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"$BASE/auth" | sed -n '/name="auth_session_id"/{s/.*value="\([^"]*\)".*/\1/p;q;}')
CODE=$(curl -sS -D - -o /dev/null -c cookies-trail.txt \
-H 'x-request-id: trail-sign-in' \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery' \
"$LOGIN" | sed -n 's/.*[?&]code=\([^&[:space:]]*\).*/\1/p' | tr -d '\r')
REFRESH_TOKEN=$(curl -sS -H 'x-request-id: trail-code' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode "code_verifier=$VERIFIER" "$BASE/token" \
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
curl -sS -o /dev/null -w '%{http_code}\n' -H 'x-request-id: trail-refresh' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode 'client_id=demo-spa' "$BASE/token"
PAGE=$(curl -sS -b cookies-trail.txt "$BASE/logout")
SESSION_ID=$(printf '%s' "$PAGE" | sed -n 's/.*name="session_id" value="\([^"]*\)".*/\1/p')
CSRF=$(printf '%s' "$PAGE" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p')
curl -sS -b cookies-trail.txt -o /dev/null -w '%{http_code}\n' \
-H 'x-request-id: trail-logout' \
--data-urlencode "session_id=$SESSION_ID" \
--data-urlencode "csrf=$CSRF" "$BASE/logout"
for EVENT_TYPE in token session; do
curl -sS -G -H "Authorization: Bearer $ADMIN_TOKEN" \
--data-urlencode "event_type=$EVENT_TYPE" \
--data-urlencode "from=$SINCE" \
http://localhost:3082/admin/tenants/demo/audit
echo
done
The refresh's status, the logout's, then the token page and the
session page, newest first:
200
200
{"items":[{"id":"01a0ee96-87dd-7f97-b784-a892d1af681b","occurred_at":"2026-09-29T19:13:57.706Z","event_type":"token","action":"token.refresh","outcome":"allowed","actor_tenant_id":"01a0ee8f-032e-718b-b030-be2b154a9e99","actor_subject_id":"01a0ee96-6112-7cc3-a854-5f4ba72b225c","actor_client_id":"01a0ee96-84ed-7dd5-9923-92767eef6df0","actor_name":"ada","actor_origin":"tenant","resource_type":"grant","resource_id":"01a0ee96-8791-7f44-8bae-5ec847f7a4c2","request_id":"trail-refresh","ip":"172.22.0.1","detail":{"scope":"openid"}},{"id":"01a0ee96-8798-708b-bf8c-d732e7e83210","occurred_at":"2026-09-29T19:13:57.630Z","event_type":"token","action":"token.issue","outcome":"allowed","actor_tenant_id":"01a0ee8f-032e-718b-b030-be2b154a9e99","actor_subject_id":"01a0ee96-6112-7cc3-a854-5f4ba72b225c","actor_client_id":"01a0ee96-84ed-7dd5-9923-92767eef6df0","actor_name":"ada","actor_origin":"tenant","resource_type":"grant","resource_id":"01a0ee96-8791-7f44-8bae-5ec847f7a4c2","request_id":"trail-code","ip":"172.22.0.1","detail":{"scope":"openid","grant_type":"authorization_code"}}]}
{"items":[{"id":"01a0ee96-885c-7fc5-aef6-3fd42f3b388f","occurred_at":"2026-09-29T19:13:57.851Z","event_type":"session","action":"session.ended","outcome":"allowed","actor_tenant_id":"01a0ee8f-032e-718b-b030-be2b154a9e99","actor_subject_id":"01a0ee96-6112-7cc3-a854-5f4ba72b225c","actor_client_id":null,"actor_name":"ada","actor_origin":"tenant","resource_type":"session","resource_id":"01a0ee96-874e-7f1b-a875-ff5846806788","request_id":"trail-logout","ip":"172.22.0.1","detail":{"via":"logout"}},{"id":"01a0ee96-8750-7584-aab5-50435e2d0819","occurred_at":"2026-09-29T19:13:57.577Z","event_type":"session","action":"session.created","outcome":"allowed","actor_tenant_id":"01a0ee8f-032e-718b-b030-be2b154a9e99","actor_subject_id":"01a0ee96-6112-7cc3-a854-5f4ba72b225c","actor_client_id":"01a0ee96-84ed-7dd5-9923-92767eef6df0","actor_name":"ada","actor_origin":"tenant","resource_type":"session","resource_id":"01a0ee96-874e-7f1b-a875-ff5846806788","request_id":"trail-sign-in","ip":"172.22.0.1","detail":{}}]}
After demo-spa was created in demo through POST /clients as a public
client and ada seeded there with odudu seed user, so each row answers
actor_name and actor_origin as well; the ids are a fresh run's. The ids the
rows name:
docker compose exec -T postgres psql -U odudu -d odudu -c \
"select t.id as demo, c.id as demo_spa, u.subject_id as ada
from tenants t
join clients c on c.tenant_id = t.id and c.client_id = 'demo-spa'
join users u on u.tenant_id = t.id and u.username = 'ada'
where t.name = 'demo';"
demo | demo_spa | ada
--------------------------------------+--------------------------------------+--------------------------------------
01a0ee8f-032e-718b-b030-be2b154a9e99 | 01a0ee96-84ed-7dd5-9923-92767eef6df0 | 01a0ee96-6112-7cc3-a854-5f4ba72b225c
(1 row)
The four requests sent with an x-request-id left one row each on these
pages, under that id, and no row on either page carries any other — so
nothing else in demo wrote a token or session row after $SINCE. The
login also wrote a login.password row, as what a refused login leaves
behind shows for a refusal; it is an
authentication row, so neither page above returns it. token.issue and
token.refresh name one grant, which rotation reuses; session.created and
session.ended name one session. actor_tenant_id is demo on all four:
it names the tenant the actor belongs to, which differs from the row's own
only when the caller came from elsewhere — a system administrator's admin
rows, or a token.foreign_issuer refusal. session.ended names no client
whichever way the session ends, since ending it ends it for every client
that shared it. The confirmation page the logout fetched first wrote
nothing, and carried no x-request-id.