Net-Async-Zitadel

CPAN Version License

Async ZITADEL client built on IO::Async and Net::Async::HTTP. All methods return Future objects (_f suffix convention).

The API surface mirrors WWW::Zitadel exactly — every synchronous method has an async _f twin.

Installation

cpanm Net::Async::Zitadel

For local development:

cpanm --installdeps .
prove -lr t

Quickstart

Unified entrypoint (Net::Async::Zitadel)

use IO::Async::Loop;
use Net::Async::Zitadel;

my $loop = IO::Async::Loop->new;

my $z = Net::Async::Zitadel->new(
    issuer => 'https://zitadel.example.com',
    token  => $ENV{ZITADEL_PAT},  # only needed for management calls
);
$loop->add($z);

# Async token verification
my $claims = $z->oidc->verify_token_f($jwt, audience => 'my-client-id')->get;

# Async management call
my $projects = $z->management->list_projects_f(limit => 20)->get;

OIDC client

use IO::Async::Loop;
use Net::Async::Zitadel::OIDC;
use Net::Async::HTTP;

my $loop = IO::Async::Loop->new;
my $http = Net::Async::HTTP->new;
$loop->add($http);

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

# Discovery metadata (cached 1h by default)
my $doc = $oidc->discovery_f->get;

# Verify JWT (auto-retries with fresh JWKS on key rotation)
my $claims = $oidc->verify_token_f($jwt, audience => 'my-client-id')->get;

# UserInfo
my $profile = $oidc->userinfo_f($access_token)->get;

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

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

OIDC caching

JWKS and discovery documents are cached with configurable TTLs:

my $oidc = Net::Async::Zitadel::OIDC->new(
    issuer        => 'https://zitadel.example.com',
    http          => $http,
    discovery_ttl => 3600,   # seconds; 0 = no cache
    jwks_ttl      => 300,    # seconds; 0 = no cache
);

# Force a JWKS refresh (e.g. after suspected key rotation)
$oidc->jwks_f(force_refresh => 1)->get;

Concurrent JWKS refresh requests are automatically coalesced — if a refresh is already in-flight, subsequent callers receive the same Future rather than triggering a second HTTP request.

Management API client

use IO::Async::Loop;
use Net::Async::Zitadel;

my $loop = IO::Async::Loop->new;
my $z = Net::Async::Zitadel->new(
    issuer => 'https://zitadel.example.com',
    token  => $ENV{ZITADEL_PAT},
);
$loop->add($z);

my $mgmt = $z->management;

# Human users
my $user = $mgmt->create_human_user_f(
    user_name  => 'alice',
    first_name => 'Alice',
    last_name  => 'Smith',
    email      => 'alice@example.com',
)->get;
$mgmt->set_password_f($user->{userId}, password => 'ch@ngeMe!')->get;

# Service users + machine keys
my $svc = $mgmt->create_service_user_f(
    user_name => 'ci-bot',
    name      => 'CI Bot',
)->get;
my $key = $mgmt->add_machine_key_f($svc->{userId})->get;

# Projects and OIDC apps
my $project = $mgmt->create_project_f(name => 'My Project')->get;
my $app = $mgmt->create_oidc_app_f(
    $project->{id},
    name          => 'web-client',
    redirect_uris => ['https://app.example.com/callback'],
)->get;

# Roles and grants
$mgmt->add_project_role_f($project->{id}, role_key => 'admin')->get;
$mgmt->create_user_grant_f(
    user_id    => $user->{userId},
    project_id => $project->{id},
    role_keys  => ['admin'],
)->get;

# Identity Providers
my $idp = $mgmt->create_oidc_idp_f(
    name          => 'Google',
    client_id     => $google_client_id,
    client_secret => $google_client_secret,
    issuer        => 'https://accounts.google.com',
)->get;
$mgmt->activate_idp_f($idp->{idp}{id})->get;

Composing Futures

Because all methods return Futures you can chain and fan-out operations:

# Parallel: fetch user and project list at the same time
Future->needs_all(
    $mgmt->get_user_f($user_id),
    $mgmt->list_projects_f,
)->then(sub {
    my ($user, $projects) = @_;
    # both resolved
})->get;

# Sequential chain
$mgmt->create_project_f(name => 'Demo')
    ->then(sub {
        my ($project) = @_;
        $mgmt->create_oidc_app_f(
            $project->{id},
            name          => 'demo-app',
            redirect_uris => ['https://demo.example.com/cb'],
        );
    })->get;

Authentication

Error handling

All errors are returned as failed Futures (or thrown synchronously for validation errors before any HTTP call). Failures are Net::Async::Zitadel::Error subclass objects that stringify to their message:

use Net::Async::Zitadel::Error;

$mgmt->get_user_f($user_id)->catch(sub {
    my ($err) = @_;
    if (ref $err && $err->isa('Net::Async::Zitadel::Error::API')) {
        warn "HTTP: ", $err->http_status, "\n";
        warn "Msg:  ", $err->api_message,  "\n";
    }
    Future->fail($err);  # re-throw
})->get;

Three exception types:

| Class | When raised | |---|---| | Net::Async::Zitadel::Error::Validation | Missing/invalid arguments, empty issuer/base_url | | Net::Async::Zitadel::Error::Network | OIDC endpoint HTTP failures | | Net::Async::Zitadel::Error::API | Management API non-2xx responses |

Testing

The offline test suite covers all modules without needing a real ZITADEL instance:

prove -lr t

To run live integration tests against a real ZITADEL instance:

ZITADEL_ISSUER='https://your-zitadel.example.com' \
ZITADEL_TOKEN='your-pat' \
ZITADEL_CLIENT_ID='...' \
ZITADEL_CLIENT_SECRET='...' \
prove -lv t/10-integration.t

Examples

Ready-to-run examples in examples/:

# Verify a JWT
ZITADEL_ISSUER='https://your-zitadel.example.com' \
ZITADEL_TOKEN='eyJ...' \
perl examples/verify_token.pl

# Obtain a client credentials token
ZITADEL_ISSUER='https://your-zitadel.example.com' \
ZITADEL_CLIENT_ID='...' \
ZITADEL_CLIENT_SECRET='...' \
perl examples/client_credentials.pl

# Async user/project management
ZITADEL_ISSUER='https://your-zitadel.example.com' \
ZITADEL_TOKEN='...' \
ZITADEL_USER_ID='...' \
perl examples/manage_users.pl

API Overview

Net::Async::Zitadel::OIDC

Net::Async::Zitadel::Management

Net::Async::Zitadel::Error

See also