WWW-Zitadel

CPAN Version License

Perl client for ZITADEL with two focused clients:

Installation

cpanm WWW::Zitadel

For local development:

cpanm --installdeps .
prove -lr t

Quickstart

Unified entrypoint (WWW::Zitadel)

use WWW::Zitadel;

my $zitadel = WWW::Zitadel->new(
    issuer => 'https://zitadel.example.com',
    token  => $ENV{ZITADEL_PAT},
);

# OIDC access
my $claims = $zitadel->oidc->verify_token($jwt, audience => 'my-client-id');

# Management API access
my $projects = $zitadel->management->list_projects(limit => 20);

OIDC client

use WWW::Zitadel::OIDC;

my $oidc = WWW::Zitadel::OIDC->new(
    issuer => 'https://zitadel.example.com',
);

# Read discovery metadata
my $discovery = $oidc->discovery;

# Verify JWT using issuer JWKS (with auto-refresh retry on key rotation)
my $claims = $oidc->verify_token(
    $jwt,
    audience => 'my-client-id',
);

# UserInfo endpoint
my $profile = $oidc->userinfo($access_token);

# Introspection endpoint (client credentials required)
my $introspection = $oidc->introspect(
    $access_token,
    client_id     => $client_id,
    client_secret => $client_secret,
);

# Client credentials grant
my $cc = $oidc->client_credentials_token(
    client_id     => $client_id,
    client_secret => $client_secret,
    scope         => 'openid profile',
);

# Refresh token grant
my $refreshed = $oidc->refresh_token(
    $refresh_token,
    client_id     => $client_id,
    client_secret => $client_secret,
);

Management API client

use WWW::Zitadel::Management;

my $mgmt = WWW::Zitadel::Management->new(
    base_url => 'https://zitadel.example.com',
    token    => $ENV{ZITADEL_PAT},
);

# Human users
my $users = $mgmt->list_users(limit => 50);
my $user = $mgmt->create_human_user(
    user_name  => 'alice',
    first_name => 'Alice',
    last_name  => 'Smith',
    email      => 'alice@example.com',
);
$mgmt->set_password($user->{userId}, password => 'ch@ngeMe!');
$mgmt->set_user_metadata($user->{userId}, 'department', 'engineering');

# Service (machine) users + machine keys for JWT auth
my $svc = $mgmt->create_service_user(
    user_name => 'ci-bot',
    name      => 'CI Bot',
);
my $key = $mgmt->add_machine_key($svc->{userId});

# Projects
my $project = $mgmt->create_project(name => 'My Project');

# OIDC app inside a project
my $app = $mgmt->create_oidc_app(
    $project->{id},
    name          => 'web-client',
    redirect_uris => ['https://app.example.com/callback'],
);

# Update an OIDC app (all keys in snake_case, same as create)
$mgmt->update_oidc_app($project->{id}, $app->{appId},
    redirect_uris => ['https://app.example.com/callback', 'https://app.example.com/silent'],
    dev_mode      => JSON::MaybeXS::false,
);

# Roles and grants
$mgmt->add_project_role(
    $project->{id},
    role_key     => 'admin',
    display_name => 'Administrator',
);
$mgmt->create_user_grant(
    user_id    => $user->{userId},
    project_id => $project->{id},
    role_keys  => ['admin'],
);

# Organizations
my $orgs = $mgmt->list_orgs;
$mgmt->update_org(name => 'Acme Corp');

# Identity Providers (social login / federated IdPs)
my $idp = $mgmt->create_oidc_idp(
    name          => 'Google',
    client_id     => $google_client_id,
    client_secret => $google_client_secret,
    issuer        => 'https://accounts.google.com',
);
$mgmt->activate_idp($idp->{idp}{id});

Sharing a single LWP::UserAgent (connection pooling)

Passing a shared UA instance to both clients lets them reuse the same HTTP keep-alive connections to the ZITADEL server:

use LWP::UserAgent;
use WWW::Zitadel::OIDC;
use WWW::Zitadel::Management;

my $ua = LWP::UserAgent->new(timeout => 30);

my $oidc = WWW::Zitadel::OIDC->new(
    issuer => 'https://zitadel.example.com',
    ua     => $ua,
);
my $mgmt = WWW::Zitadel::Management->new(
    base_url => 'https://zitadel.example.com',
    token    => $ENV{ZITADEL_PAT},
    ua       => $ua,
);

Or use the WWW::Zitadel façade, which automatically shares one UA when you access both ->oidc and ->management.

Filtering with the queries parameter

All list_* methods accept a queries arrayref using ZITADEL's native query filter format. Each element is a query object as documented in the ZITADEL Management API reference.

# Find users whose display name contains "alice"
my $result = $mgmt->list_users(
    queries => [
        {
            displayNameQuery => {
                displayName => 'alice',
                method      => 'TEXT_QUERY_METHOD_CONTAINS',
            },
        },
    ],
);

# Find projects by name prefix
my $projects = $mgmt->list_projects(
    queries => [
        { nameQuery => { name => 'My', method => 'TEXT_QUERY_METHOD_STARTS_WITH' } },
    ],
);

Token refresh strategy

ZITADEL access tokens are short-lived (default 12h). Common patterns:

JWKS key rotation

verify_token automatically retries with a fresh JWKS fetch when signature verification fails (transparent key rotation handling). The JWKS is cached in the WWW::Zitadel::OIDC object for the lifetime of the instance. Force an immediate refresh:

$oidc->jwks(force_refresh => 1);

If you run multiple processes, each process maintains its own JWKS cache. Under normal ZITADEL key rotation schedules (months) this is not an issue. Set the object lifetime to match your deployment's rotation period if you need tighter guarantees.

Authentication

Error handling

All errors are thrown as WWW::Zitadel::Error subclass objects. Because they overload "", existing eval/$@ string-matching patterns continue to work. For typed dispatch use isa:

use WWW::Zitadel::Error;

eval { $mgmt->get_user($user_id) };
if (my $err = $@) {
    if (ref $err && $err->isa('WWW::Zitadel::Error::API')) {
        # $err->http_status — e.g. "404 Not Found"
        # $err->api_message — message field from the JSON body
        warn "API error: $err";
    }
    elsif (ref $err && $err->isa('WWW::Zitadel::Error::Validation')) {
        warn "Bad argument: $err";
    }
    elsif (ref $err && $err->isa('WWW::Zitadel::Error::Network')) {
        warn "Network/HTTP error: $err";
    }
    else {
        die $err;  # re-throw unknown
    }
}

Three exception types:

| Class | When raised | |---|---| | WWW::Zitadel::Error::Validation | Missing/invalid arguments, empty issuer/base_url | | WWW::Zitadel::Error::Network | Discovery, JWKS, UserInfo, Token endpoint HTTP failures | | WWW::Zitadel::Error::API | Management API non-2xx responses |

Development workflow

Typical local workflow:

cd /storage/raid/home/getty/dev/perl/p5-www-zitadel
cpanm --installdeps .
prove -lr t

Before release, also run opt-in live tests against a real issuer:

ZITADEL_LIVE_TEST=1 \
ZITADEL_ISSUER='https://your-zitadel.example.com' \
prove -lv t/90-live-zitadel.t

Claude skill

Project-local ZITADEL skills:

Testing

The test suite is fully offline and covers:

Run all tests:

prove -lr t

Live tests against a real ZITADEL instance

Enable optional live tests with environment variables:

export ZITADEL_LIVE_TEST=1
export ZITADEL_ISSUER='https://your-zitadel.example.com'

# Optional extras:
export ZITADEL_PAT='...'
export ZITADEL_ACCESS_TOKEN='...'
export ZITADEL_CLIENT_ID='...'
export ZITADEL_CLIENT_SECRET='...'
export ZITADEL_INTROSPECT_TOKEN='...'

prove -lv t/90-live-zitadel.t

Kubernetes pod test (real cluster + real ZITADEL endpoint)

This test creates a temporary pod and validates that the pod can reach the ZITADEL discovery endpoint:

export ZITADEL_K8S_TEST=1
export ZITADEL_ISSUER='https://your-zitadel.example.com'

# Optional:
export ZITADEL_KUBECONFIG='/storage/raid/home/getty/avatar/.kube/config'
export ZITADEL_K8S_NAMESPACE='default'
export ZITADEL_K8S_CONTEXT='your-context'

prove -lv t/91-k8s-pod.t

End-to-end deployment on your cluster (Gateway API + cert-manager)

This repo includes a full deployment helper for your cluster setup:

cd /storage/raid/home/getty/dev/perl/p5-www-zitadel
script/deploy-k8s-zitadel.sh

Included assets:

Useful overrides:

KUBECONFIG_PATH=/storage/raid/home/getty/avatar/.kube/config \
DOMAIN=zitadel.avatar.conflict.industries \
NAMESPACE=zitadel \
ZITADEL_IMAGE_REPOSITORY=src.ci/srv/zitadel \
ZITADEL_IMAGE_TAG=pg18-fix \
script/deploy-k8s-zitadel.sh

Important for PostgreSQL 18:

You can run both live suites together:

ZITADEL_LIVE_TEST=1 ZITADEL_K8S_TEST=1 \
ZITADEL_ISSUER='https://your-zitadel.example.com' \
prove -lv t/90-live-zitadel.t t/91-k8s-pod.t

Examples

Ready-to-run examples are in examples/:

Example usage:

ZITADEL_ISSUER='https://your-zitadel.example.com' \
ZITADEL_ACCESS_TOKEN='...' \
examples/verify_token.pl

ZITADEL_ISSUER='https://your-zitadel.example.com' \
ZITADEL_PAT='...' \
examples/bootstrap_project.pl

API Overview

WWW::Zitadel::OIDC

WWW::Zitadel::Management

WWW::Zitadel::Error

See also