CertHub — Temporary Code Snapshot

Temporary working note. Not linked from CONTENTS. Contains the post-review versions of CertHub::Client and cert_request.pl, plus a record of deferred improvements.

Pre-review backups: CertHub/Client_pre_review.pm and cert_request_pre_review.pl. Full improvement log: CertHub/IMPROVEMENTS.md.

Running cert_request.pl

# Required
BASE_URL=https://uat-certhub.example.com \
USR=api_user \
PW=api_pass \
HOST=myserver.critchley.biz \
DOWNLOAD_PW=keystorepassword \
NID=45306-1 \
EMAIL=you@db.com \
perl cert_request.pl

# Optional overrides (defaults shown)
CERT_TYPE=gcp-ssl-client-server   # certificate profile
POLL_INTERVAL=5                   # seconds between status polls
POLL_TIMEOUT=300                  # give up after N seconds
INSECURE=1                        # disable TLS verification (UAT only)
package CertHub::Client;

# Data::Dumper and JSON are retained for debug use only.
# They are loaded at runtime when DEBUG is set to a non-empty, non-zero value
# so that normal production runs incur no cost from these imports.
if ($ENV{DEBUG}) {
    require Data::Dumper;
    require JSON;
}

use strict;
use warnings;

# Moo gives us a small, modern OO layer without pulling in full Moose.
use Moo;

# Carp is used for caller-friendly exceptions.
use Carp qw(croak);

# Mojo::UserAgent is the required HTTP client for this module.
use Mojo::UserAgent;

# Mojo::URL is useful for safe URL/query manipulation.
use Mojo::URL;

# Mojo::Util provides URL escaping and base64 helpers for HTTP Basic auth.
use Mojo::Util qw(url_escape b64_encode);

# MIME::Base64 is used for decoding downloaded certificate/keystore content.
use MIME::Base64 qw(decode_base64);

# Time::HiRes allows fractional sleeps and reliable timeout calculations.
use Time::HiRes qw(time sleep);

# POSIX is used for a compact UTC timestamp in generated clientRef values.
use POSIX qw(strftime);

# Bytes from /dev/urandom are used for uniqueness in generated clientRef values.
use Fcntl qw(:DEFAULT);

# Needed for old-Mojo insecure fallback via IO::Socket::SSL.
use IO::Socket::SSL qw(SSL_VERIFY_NONE);


our $VERSION = '0.01';

my $OLD_MOJO_INSECURE_HACK_INSTALLED;


# -------------------------------------------------------------------------
# Constructor attributes: connection/authentication.
# -------------------------------------------------------------------------

has base_url => (
    is       => 'ro',
    required => 1,
);

has token_url => (
    is => 'ro',
);

has username => (
    is => 'ro',
);

has password => (
    is => 'ro',
);

has access_token => (
    is        => 'rw',
    predicate => 'has_access_token',
);

has auth_mode => (
    is      => 'ro',
    default => sub { 'oauth' },  # 'oauth' or 'basic'
);

has ca_file => (
    is => 'ro',
);

has insecure => (
    is      => 'ro',
    default => sub { 0 },
);

has connect_timeout => (
    is      => 'ro',
    default => sub { 30 },
);

has request_timeout => (
    is      => 'ro',
    default => sub { 120 },
);


# -------------------------------------------------------------------------
# Constructor attributes: useful payload defaults.
#
# These values are not required for the low-level request methods, but they
# make the helper payload builders much easier to use from small scripts.
# -------------------------------------------------------------------------

has nar_id => (
    is => 'ro',
);

has platform => (
    is      => 'ro',
    default => sub { 'DAP' },
);

has email_contact => (
    is => 'ro',
);

has change_id => (
    is => 'ro',
);

has cert_type => (
    is      => 'ro',
    default => sub { 'gcp-ssl-client-server' },
);

has organisation => (
    is      => 'ro',
    default => sub { 'Deutsche Bank AG' },
);

has org_unit => (
    is      => 'ro',
    default => sub { 'PKI' },
);


# -------------------------------------------------------------------------
# Lifecycle attributes: populated after onboard_newcert() is called.
# These let callers (and polling loops) refer back to the submitted request
# without needing to parse the response themselves.
# -------------------------------------------------------------------------

has last_client_ref => (
    is => 'rw',
);

has onboard_task_id => (
    is => 'rw',
);

has provide_results_task_id => (
    is => 'rw',
);


# -------------------------------------------------------------------------
# Old Mojo insecure compatibility.
#
# According to old_Mojo::UserAgent.txt, very old Mojo::UserAgent has neither
# the modern "insecure" attribute nor "tls_options". According to
# IO::Socket::SSL.txt, disabling verification can be forced with:
#
#   SSL_verify_mode     => SSL_VERIFY_NONE
#   SSL_verifycn_scheme => 'none'
#
# set_args_filter_hack is process-global, so this is only an emergency
# fallback for old Mojo installs.
# -------------------------------------------------------------------------

sub _install_old_mojo_insecure_hack {
    return if $OLD_MOJO_INSECURE_HACK_INSTALLED++;

    IO::Socket::SSL::set_args_filter_hack(sub {
        my ($is_server, $args) = @_;

        # Only weaken client-side TLS.
        return if $is_server;

        # curl -k equivalent: disable chain and hostname verification.
        $args->{SSL_verify_mode}     = SSL_VERIFY_NONE;
        $args->{SSL_verifycn_scheme} = 'none';

        # Remove other verification-related settings that could interfere.
        delete @{$args}{qw(
            SSL_ca
            SSL_ca_file
            SSL_ca_path
            SSL_verify_callback
            SSL_fingerprint
            SSL_force_fingerprint
        )};
    });

    return;
}


# -------------------------------------------------------------------------
# Lazy Mojo::UserAgent construction.
#
# This centralizes TLS and timeout configuration. In modern Mojo,
# Mojo::UserAgent.txt documents "insecure" and "tls_options". In old Mojo,
# old_Mojo::UserAgent.txt shows neither, so we must fall back to
# IO::Socket::SSL::set_args_filter_hack as documented in IO::Socket::SSL.txt.
# -------------------------------------------------------------------------

has ua => (
    is      => 'lazy',
    builder => '_build_ua',
);

sub _build_ua {
    my ($self) = @_;

    my $ua = Mojo::UserAgent->new;

    $ua->connect_timeout($self->connect_timeout);
    $ua->request_timeout($self->request_timeout);

    if ($self->insecure) {
        if ($ua->can('insecure')) {
            # Modern Mojo::UserAgent, per Mojo::UserAgent.txt
            $ua->insecure(1);
        }
        elsif ($ua->can('tls_options')) {
            # Intermediate Mojo with tls_options support
            $ua->tls_options({
                SSL_verify_mode     => SSL_VERIFY_NONE,
                SSL_verifycn_scheme => 'none',
            });
        }
        else {
            # Very old Mojo::UserAgent, per old_Mojo::UserAgent.txt
            _install_old_mojo_insecure_hack();
        }
    }
    elsif (defined $self->ca_file && length $self->ca_file) {
        # In old_Mojo::UserAgent.txt, "ca" is used for peer verification and
        # also activates hostname verification, so only set it when not using
        # insecure mode.
        $ua->ca($self->ca_file);
    }

    return $ua;
}


# -------------------------------------------------------------------------
# URL helper.
#
# certomato.py configures a base URL ending in /certhub/v1/ and then appends
# endpoint paths such as certhub/api/application/onboard/newcert.
#
# This helper keeps that convention and avoids accidental double slashes.
# -------------------------------------------------------------------------

sub _url_for {
    my ($self, $path) = @_;

    croak 'path is required' unless defined $path && length $path;

    my $base = $self->base_url;
    $base =~ s{/+\z}{};

    $path =~ s{\A/+}{};

    return "$base/$path";
}


# -------------------------------------------------------------------------
# Response helper.
#
# old_Mojo::UserAgent.txt prominently shows transaction-level error handling
# using $tx->success / $tx->error. Modern Mojo::UserAgent.txt uses $tx->result
# more heavily. This helper stays friendly to older Mojo by checking
# transaction-level errors first, then returning a response object.
# -------------------------------------------------------------------------

sub _checked_res {
    my ($self, $tx, $context) = @_;

    croak "$context failed: no transaction returned"
        unless defined $tx;

    # Modern Mojo: $tx->result croaks on connection errors and returns the
    # response (even 4xx/5xx) otherwise. Use it when available.
    if ($tx->can('result')) {
        my $res = $tx->result;

        if (my $err = $res->error) {
            my $status = $err->{code} // 'transport';
            my $msg    = $err->{message} // 'unknown error';
            croak "$context failed: status=$status message=$msg body=" . ($res->body // '');
        }

        return $res;
    }

    # Old Mojo: $tx->success returns undef on any error.
    if ($tx->can('success')) {
        if (my $res = $tx->success) {
            return $res;
        }

        my $err    = $tx->error || {};
        my $status = $err->{code} // 'transport';
        my $msg    = $err->{message} // 'unknown error';
        my $body   = ($tx->can('res') && $tx->res) ? ($tx->res->body // '') : '';

        croak "$context failed: status=$status message=$msg body=$body";
    }

    croak "$context failed: unsupported Mojo::Transaction object";
}


# -------------------------------------------------------------------------
# OAuth2 client-credentials authentication.
#
# If the caller supplied access_token, use it directly. Otherwise request a
# token using the same basic idea as certomato.py:
#
#   POST token_url
#   HTTP Basic auth username:password
#   form grant_type=client_credentials
#
# The token is retained on the object for subsequent API calls.
# -------------------------------------------------------------------------

sub authenticate {
    my ($self) = @_;

    return $self->access_token if $self->has_access_token && length $self->access_token;

    for my $field (qw(token_url username password)) {
        my $value = $self->$field;
        croak "Cannot authenticate: $field is required unless access_token is supplied"
            unless defined $value && length $value;
    }

    my $basic = b64_encode($self->username . ':' . $self->password, '');

    my $tx = $self->ua->post(
        $self->token_url
            => {
                Authorization => "Basic $basic",
                Accept        => 'application/json',
            }
            => form => {
                grant_type => 'client_credentials',
            }
    );

    my $res = $self->_checked_res($tx, 'Token request');

    my $json = $res->json;
    croak 'Token response was not valid JSON'
        unless defined $json && ref $json eq 'HASH';

    my $token = $json->{access_token};
    croak 'Token response did not contain access_token'
        unless defined $token && length $token;

    $self->access_token($token);

    return $token;
}


# -------------------------------------------------------------------------
# Authentication header helper.
#
# Selects Basic or Bearer auth based on the auth_mode attribute.
# auth_mode 'basic' uses the username/password attributes directly.
# auth_mode 'oauth' (default) calls authenticate() to obtain a bearer token.
# -------------------------------------------------------------------------

sub _auth_header {
    my ($self) = @_;

    my $mode = lc($self->auth_mode // 'oauth');

    if ($mode eq 'basic') {
        for my $field (qw(username password)) {
            croak "Basic auth requires $field"
                unless defined $self->$field && length $self->$field;
        }
        my $credentials = b64_encode($self->username . ':' . $self->password, '');
        return "Basic $credentials";
    }

    if ($mode eq 'oauth') {
        my $token = $self->authenticate;
        return "Bearer $token";
    }

    croak "Unsupported auth_mode: $mode";
}


# -------------------------------------------------------------------------
# Generic JSON request helper.
#
# All normal CertHub calls go through this method. It:
#
#   * selects auth via _auth_header (Basic or OAuth Bearer);
#   * constructs the full URL;
#   * sends JSON for POST requests;
#   * sends query parameters for GET requests if payload is supplied;
#   * throws detailed errors including the response body;
#   * returns decoded JSON.
# -------------------------------------------------------------------------

sub request_json {
    my ($self, $method, $path, $payload) = @_;

    croak 'HTTP method is required' unless defined $method && length $method;
    croak 'path is required'        unless defined $path   && length $path;

    $method  = uc $method;
    $payload = {} unless defined $payload;

    my $url = $self->_url_for($path);

    my $headers = {
        Accept        => 'application/json',
        Authorization => $self->_auth_header,
    };

    my $tx;

    if ($method eq 'POST') {
        $tx = $self->ua->post($url => $headers => json => $payload);
    }
    elsif ($method eq 'GET') {
        my $u = Mojo::URL->new($url);

        if (ref $payload eq 'HASH' && keys %{$payload}) {
            $u->query($payload);
        }

        $tx = $self->ua->get($u => $headers);
    }
    else {
        croak "Unsupported HTTP method: $method";
    }

    my $res = $self->_checked_res($tx, "$method $url");

    my $json = $res->json;

    croak "$method $url returned non-JSON response: " . ($res->body // '')
        unless defined $json;

    return $json;
}

# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   POST /certhub/api/validate/certrequest
#
# This maps to CertRequestValidationReq in openapi.json.
# -------------------------------------------------------------------------

sub validate_cert_request {
    my ($self, $payload) = @_;

    croak 'validate_cert_request requires a payload hashref'
        unless ref $payload eq 'HASH';

    return $self->request_json(
        POST => 'certhub/api/validate/certrequest',
        $payload,
    );
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   POST /certhub/api/application/onboard/newcert
#
# This maps to OnboardingReqNewCert in openapi.json and to create_certificate()
# in certomato.py.
# -------------------------------------------------------------------------

sub onboard_newcert {
    my ($self, $payload) = @_;

    croak 'onboard_newcert requires a payload hashref'
        unless ref $payload eq 'HASH';

    # Save clientRef from the payload before the HTTP call so it is available
    # for recovery even if the request times out after the server accepted it.
    $self->last_client_ref($payload->{clientRef})
        if defined $payload->{clientRef} && length $payload->{clientRef};

    # Clear stale lifecycle state from any previous request on this object.
    $self->onboard_task_id(undef);
    $self->provide_results_task_id(undef);

    my $response = $self->request_json(
        POST => 'certhub/api/application/onboard/newcert',
        $payload,
    );

    # Update from response (authoritative); payload value is the fallback.
    $self->last_client_ref($response->{clientRef})
        if defined $response->{clientRef} && length $response->{clientRef};

    $self->onboard_task_id($self->find_onboard_task_id($response));
    $self->provide_results_task_id($self->find_provide_results_task_id($response));

    return $response;
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   GET /certhub/task/result/{cmsRef}
#
# In the intended workflow, cmsRef is the id of the "onboard cert request"
# task. Poll this task to know when the certificate has been issued.
# The "provide results" task is a later notification task (~5 min) and is
# not needed to confirm certificate issuance.
# -------------------------------------------------------------------------

sub task_result {
    my ($self, $cms_ref) = @_;

    croak 'task_result requires cms_ref'
        unless defined $cms_ref && length $cms_ref;

    return $self->request_json(
        GET => 'certhub/task/result/' . url_escape($cms_ref),
    );
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   GET /certhub/task/resultByClientRef/{clientRef}
#
# This is useful for recovery/debugging if a caller has clientRef but not the
# task id.
# -------------------------------------------------------------------------

sub task_result_by_client_ref {
    my ($self, $client_ref) = @_;

    croak 'task_result_by_client_ref requires client_ref'
        unless defined $client_ref && length $client_ref;

    return $self->request_json(
        GET => 'certhub/task/resultByClientRef/' . url_escape($client_ref),
    );
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   POST /certhub/api/request/download
#
# This endpoint is documented in openapi.json and discussed in download_cert.ans
# and download_cert2.ans. certomato.py does not currently wrap it.
# -------------------------------------------------------------------------

sub download {
    my ($self, $payload) = @_;

    croak 'download requires a payload hashref'
        unless ref $payload eq 'HASH';

    return $self->request_json(
        POST => 'certhub/api/request/download',
        $payload,
    );
}


# -------------------------------------------------------------------------
# Useful extra endpoint:
#
#   POST /certhub/api/validate/domain
#
# certomato.py has this, and what_I_need_to_do.ans discusses it as a useful
# pre-check.
# -------------------------------------------------------------------------

sub validate_domain {
    my ($self, $nar_id, $domains) = @_;

    croak 'validate_domain requires nar_id'
        unless defined $nar_id && length $nar_id;

    croak 'validate_domain requires domains arrayref'
        unless ref $domains eq 'ARRAY';

    return $self->request_json(
        POST => 'certhub/api/validate/domain',
        {
            narId   => $nar_id,
            domains => $domains,
        },
    );
}


# -------------------------------------------------------------------------
# Useful extra endpoint:
#
#   POST /certhub/api/application/certificates
#
# This mirrors get_certificates() in certomato.py at a low level.
# -------------------------------------------------------------------------

sub query_certificates {
    my ($self, $payload) = @_;

    $payload = {} unless defined $payload;

    croak 'query_certificates payload must be a hashref'
        unless ref $payload eq 'HASH';

    return $self->request_json(
        POST => 'certhub/api/application/certificates',
        $payload,
    );
}


# -------------------------------------------------------------------------
# Metadata builder.
#
# This returns a MetaData structure as defined in openapi.json. Undefined
# values are omitted, because some fields such as changeId may not always be
# required in all environments.
# -------------------------------------------------------------------------

sub build_metadata {
    my ($self, %args) = @_;

    my %md;

    my $nar_id        = exists $args{nar_id}        ? $args{nar_id}        : $self->nar_id;
    my $email_contact = exists $args{email_contact} ? $args{email_contact} : $self->email_contact;
    my $platform      = exists $args{platform}      ? $args{platform}      : $self->platform;

    $md{narId}        = $nar_id        if defined $nar_id        && length $nar_id;
    $md{emailContact} = $email_contact if defined $email_contact && length $email_contact;
    $md{platform}     = $platform      if defined $platform      && length $platform;

    # changeId is a top-level payload field, not a metaData field. See
    # build_newcert_payload. Do not add it here.

    return \%md;
}


# -------------------------------------------------------------------------
# Validation payload builder.
#
# openapi.json documents CertRequestValidationReq as:
#
#   {
#     certType:   string,
#     parameters: map<string,array<string>>,
#     metaData:   MetaData
#   }
#
# The exact parameters can be profile-specific, so callers can pass their own
# parameters hashref. If not supplied, this helper creates a sensible minimal
# payload with commonName and dns values.
# -------------------------------------------------------------------------

sub build_validation_payload {
    my ($self, %args) = @_;

    my $host = $args{host};
    croak 'build_validation_payload requires host'
        unless defined $host && length $host;

    my $cert_type = $args{cert_type} // $self->cert_type;
    my $cn        = $args{common_name} // $host;
    my $sans      = $args{sans} // [$host];

    croak 'sans must be an arrayref'
        unless ref $sans eq 'ARRAY';

    my $parameters = $args{parameters};

    if (defined $parameters) {
        croak 'parameters must be a hashref'
            unless ref $parameters eq 'HASH';
    }
    else {
        $parameters = {
            commonName => [$cn],
            dns        => $sans,
        };
    }

    return {
        certType   => $cert_type,
        parameters => $parameters,
        metaData   => $self->build_metadata(%args),
    };
}


# -------------------------------------------------------------------------
# New certificate onboarding payload builder.
#
# This builds the payload for:
#
#   POST /certhub/api/application/onboard/newcert
#
# It is oriented toward the download-only flow requested in what_I_need_to_do.ask:
# the generated key/cert should later be downloaded via API, not pushed by SSH.
#
# Therefore deploymentLocations is omitted unless explicitly supplied.
# -------------------------------------------------------------------------

sub build_newcert_payload {
    my ($self, %args) = @_;

    my $host = $args{host};
    croak 'build_newcert_payload requires host'
        unless defined $host && length $host;

    my $download_password = $args{download_password};
    croak 'build_newcert_payload requires download_password'
        unless defined $download_password && length $download_password;

    my $client_ref = $args{client_ref} // $self->generate_client_ref($host);

    my $cert_type = $args{cert_type}   // $self->cert_type;
    my $cn        = $args{common_name} // $host;
    my $sans      = $args{sans}        // [$host];

    croak 'sans must be an arrayref'
        unless ref $sans eq 'ARRAY';

    my $cert_request = {
        certType         => $cert_type,
        commonName       => $cn,
        o                => $args{organisation} // $self->organisation,
        ou               => $args{org_unit}     // $self->org_unit,
        sans             => {
            dns => $sans,
        },
        metaData         => $self->build_metadata(%args),
        downloadPassword => $download_password,
    };

    if (defined $args{task_ref} && length $args{task_ref}) {
        $cert_request->{taskRef} = $args{task_ref};
    }

    if (exists $args{deployment_locations}) {
        croak 'deployment_locations must be an arrayref'
            unless ref $args{deployment_locations} eq 'ARRAY';

        $cert_request->{deploymentLocations} = $args{deployment_locations};
    }

    my $payload = {
        clientRef              => $client_ref,
        platform               => $args{platform} // $self->platform,
        onboardingCertRequests => [$cert_request],
    };

    my $change_id = exists $args{change_id} ? $args{change_id} : $self->change_id;

    if (defined $change_id && length $change_id) {
        $payload->{changeId} = $change_id;
    }

    return $payload;
}


# -------------------------------------------------------------------------
# Client reference generator.
#
# certomato.py generates unique clientRef strings using a prefix, date, and
# random bytes. This does the same kind of thing in Perl.
# -------------------------------------------------------------------------

sub generate_client_ref {
    my ($self, $host) = @_;

    $host //= 'unknown-host';
    $host =~ s/[^A-Za-z0-9_.:-]+/-/g;

    my $ts  = strftime('%Y%m%d%H%M%S', gmtime);
    my $rnd = _random_hex(4);

    return "cert-request-$host-$ts-$rnd";
}


# -------------------------------------------------------------------------
# Small internal random hex helper.
# -------------------------------------------------------------------------

sub _random_hex {
    my ($nbytes) = @_;

    $nbytes ||= 4;

    my $buf = '';

    if (sysopen my $fh, '/dev/urandom', O_RDONLY) {
        my $read = sysread $fh, $buf, $nbytes;
        close $fh;

        if (defined $read && $read == $nbytes) {
            return unpack('H*', $buf);
        }
    }

    # /dev/urandom was unavailable or returned short read. This should never
    # happen on any normal Linux/macOS system. Falling back to rand(), which
    # is weaker but sufficient for clientRef uniqueness. Log so this is
    # visible if it ever occurs in production.
    warn "CertHub::Client: _random_hex: /dev/urandom unavailable, falling back to rand()\n";
    return sprintf '%08x', int(rand(0xffffffff));
}


# -------------------------------------------------------------------------
# Validation response helper.
#
# CertHub validation responses contain validationResults, a map of arrays of
# FieldValidationError objects. This helper returns the ones that are marked
# as errors.
# -------------------------------------------------------------------------

sub validation_errors {
    my ($self, $validation_response) = @_;

    return [] unless ref $validation_response eq 'HASH';

    my $vr = $validation_response->{validationResults};
    return [] unless ref $vr eq 'HASH';

    my @errors;

    for my $field (sort keys %{$vr}) {
        my $items = $vr->{$field};
        next unless ref $items eq 'ARRAY';

        for my $item (@{$items}) {
            next unless ref $item eq 'HASH';

            if ($item->{error}) {
                push @errors, {
                    field   => $field,
                    code    => $item->{code},
                    message => $item->{message},
                    raw     => $item,
                };
            }
        }
    }

    return \@errors;
}


# -------------------------------------------------------------------------
# Boolean validation helper.
# -------------------------------------------------------------------------

sub has_validation_errors {
    my ($self, $validation_response) = @_;

    return @{ $self->validation_errors($validation_response) } ? 1 : 0;
}


# -------------------------------------------------------------------------
# Task helper: find a CMS task by action regex.
#
# The newcert response is a SnowResp according to openapi.json, containing
# cmsTasks. design specifically says to find the id of the task whose action
# is "provide results".
# -------------------------------------------------------------------------

sub find_task_id_by_action {
    my ($self, $snow_response, $regex) = @_;

    croak 'find_task_id_by_action requires a response hashref'
        unless ref $snow_response eq 'HASH';

    croak 'find_task_id_by_action requires a regex'
        unless ref $regex eq 'Regexp';

    my $tasks = $snow_response->{cmsTasks} // [];
    croak 'cmsTasks must be an arrayref'
        unless ref $tasks eq 'ARRAY';

    for my $task (@{$tasks}) {
        next unless ref $task eq 'HASH';

        my $action = $task->{action} // '';

        if ($action =~ $regex) {
            return $task->{id};
        }
    }

    return undef;
}


# -------------------------------------------------------------------------
# Task helper: find the "onboard cert request" task id.
# -------------------------------------------------------------------------

sub find_onboard_task_id {
    my ($self, $snow_response) = @_;

    return $self->find_task_id_by_action(
        $snow_response,
        qr/\bonboard\s+cert\s+request\b/i,
    );
}


# -------------------------------------------------------------------------
# Task helper: find the "provide results" task id.
# -------------------------------------------------------------------------

sub find_provide_results_task_id {
    my ($self, $snow_response) = @_;

    return $self->find_task_id_by_action(
        $snow_response,
        qr/\bprovide\s+results\b/i,
    );
}


# -------------------------------------------------------------------------
# Task helper: return normalized task state information.
#
# This is intentionally simple so monitor_cert_generation can print it easily.
# -------------------------------------------------------------------------

sub task_states {
    my ($self, $snow_response) = @_;

    return [] unless ref $snow_response eq 'HASH';

    my $tasks = $snow_response->{cmsTasks} // [];
    return [] unless ref $tasks eq 'ARRAY';

    my @states;

    for my $task (@{$tasks}) {
        next unless ref $task eq 'HASH';

        push @states, {
            id     => $task->{id},
            action => $task->{action},
            state  => $task->{state},
        };
    }

    return \@states;
}


# -------------------------------------------------------------------------
# Task helper: get state for a specific task id.
#
# If the response only contains one task, this method falls back to that task.
# That makes it tolerant of both detailed and narrowed task-result responses.
# -------------------------------------------------------------------------

sub state_for_task {
    my ($self, $snow_response, $task_id) = @_;

    return undef unless ref $snow_response eq 'HASH';

    my $tasks = $snow_response->{cmsTasks} // [];
    return undef unless ref $tasks eq 'ARRAY';

    if (defined $task_id) {
        for my $task (@{$tasks}) {
            next unless ref $task eq 'HASH';

            return $task->{state}
                if defined $task->{id} && "$task->{id}" eq "$task_id";
        }
    }

    if (@{$tasks} == 1 && ref $tasks->[0] eq 'HASH') {
        return $tasks->[0]{state};
    }

    return undef;
}


# -------------------------------------------------------------------------
# Poll task until final state.
#
# This implements the monitoring behaviour requested in design:
# keep checking state until it is no longer init, then return success/failure.
#
# Callers can pass on_state => sub { my ($state, $response) = @_; ... }
# to print progress while polling.
# -------------------------------------------------------------------------

sub poll_task_until_final {
    my ($self, %args) = @_;

    my $cms_ref = $args{cms_ref};
    croak 'poll_task_until_final requires cms_ref'
        unless defined $cms_ref && length $cms_ref;

    my $task_id  = $args{task_id};
    my $interval = $args{interval} // 30;
    my $timeout  = $args{timeout}  // 1800;

    my $on_state = $args{on_state};

    croak 'on_state must be a coderef if supplied'
        if defined $on_state && ref $on_state ne 'CODE';

    my %init_states = map { lc($_) => 1 } @{ $args{init_states} // [qw(init running)] };

    my %success_states = map { lc($_) => 1 } @{ $args{success_states} // ['done'] };

    my %failure_states = map { lc($_) => 1 } @{ $args{failure_states} // [qw(failed failure error cancelled canceled)] };

    my $deadline = time + $timeout;

    while (1) {
        my $response = $self->task_result($cms_ref);
        my $state    = $self->state_for_task($response, $task_id);

        $state = 'unknown' unless defined $state && length $state;

        $on_state->($state, $response) if $on_state;

        my $norm = lc $state;

        if ($success_states{$norm}) {
            return {
                success  => 1,
                state    => $state,
                response => $response,
            };
        }

        if ($failure_states{$norm}) {
            return {
                success  => 0,
                state    => $state,
                response => $response,
            };
        }

        if (!$init_states{$norm}) {
            return {
                success  => 0,
                state    => $state,
                response => $response,
            };
        }

        my $remaining = $deadline - time;

        croak "Timed out after $timeout seconds waiting for task $cms_ref"
            if $remaining <= 0;

        sleep($interval < $remaining ? $interval : $remaining);
    }
}


# -------------------------------------------------------------------------
# Task helper: extract cmsActions from a task result response.
#
# Returns an arrayref of hashrefs with keys: action, state, result.
# If task_id is supplied, only actions for that task are returned.
# -------------------------------------------------------------------------

sub task_action_results {
    my ($self, $snow_response, $task_id) = @_;

    return [] unless ref $snow_response eq 'HASH';

    my $tasks = $snow_response->{cmsTasks};
    return [] unless ref $tasks eq 'ARRAY';

    my @results;

    for my $task (@{$tasks}) {
        next unless ref $task eq 'HASH';

        if (defined $task_id) {
            next unless defined $task->{id} && "$task->{id}" eq "$task_id";
        }

        my $actions = $task->{cmsActions};
        next unless ref $actions eq 'ARRAY';

        for my $action (@{$actions}) {
            next unless ref $action eq 'HASH';

            push @results, {
                action => $action->{action},
                state  => $action->{state},
                result => $action->{result},
            };
        }
    }

    return \@results;
}


# -------------------------------------------------------------------------
# Task helper: build a human-readable failure summary from cmsActions.
#
# Returns a string like "validate_metaData[failed]: ERROR:[Unknown NAR-ID]"
# suitable for appending to an error message.
# -------------------------------------------------------------------------

sub task_failure_summary {
    my ($self, $snow_response, $task_id) = @_;

    my $results = $self->task_action_results($snow_response, $task_id);

    my @lines = map {
        sprintf '%s[%s]: %s',
            $_->{action} // '(unknown)',
            $_->{state}  // '(unknown)',
            $_->{result} // ''
    } @{$results};

    return join '; ', grep { length } @lines;
}


# -------------------------------------------------------------------------
# Build CertDownloadReq.
#
# This is the JSON clarified in download_cert2.ans:
#
#   {
#     clientRef:      "...",
#     cmsTaskId:      123456,
#     password:       "...",
#     keystoreFormat: "JKS",
#     alias:          "hostname"
#   }
# -------------------------------------------------------------------------

sub build_download_payload {
    my ($self, %args) = @_;

    for my $field (qw(client_ref cms_task_id password)) {
        croak "build_download_payload requires $field"
            unless defined $args{$field} && length $args{$field};
    }

    croak 'cms_task_id must be a positive integer'
        unless $args{cms_task_id} =~ /\A[1-9]\d*\z/;

    my $format = uc($args{keystore_format} // 'JKS');

    croak "Unsupported keystore_format: $format"
        unless $format =~ /\A(?:JKS|PKCS12|PEM)\z/;

    my $payload = {
        clientRef      => $args{client_ref},
        cmsTaskId      => int($args{cms_task_id}),
        password       => $args{password},
        keystoreFormat => $format,
    };

    if (exists $args{aliases}) {
        croak 'aliases must be an arrayref'
            unless ref $args{aliases} eq 'ARRAY';
        if (@{ $args{aliases} }) {
            $payload->{alias} = @{ $args{aliases} } == 1
                ? $args{aliases}[0]
                : $args{aliases};
        }
    }
    elsif (defined $args{alias} && length $args{alias}) {
        $payload->{alias} = $args{alias};
    }

    return $payload;
}


# -------------------------------------------------------------------------
# Convenience method: build download payload and call the endpoint.
# -------------------------------------------------------------------------

sub download_keystore {
    my ($self, %args) = @_;

    my $payload = $self->build_download_payload(%args);

    return $self->download($payload);
}


# -------------------------------------------------------------------------
# Extract base64 text from a CertDownloadResp.
#
# openapi.json says the response can contain certificateContent,
# pkcs12Content, or keystoreContent. Which one is populated depends on
# requested format and backend behaviour.
# -------------------------------------------------------------------------

sub _download_base64_text {
    my ($self, $response, $format) = @_;

    croak 'download response must be a hashref'
        unless ref $response eq 'HASH';

    $format = uc($format // 'JKS');

    my $text;

    if ($format eq 'JKS') {
        $text = $response->{keystoreContent}{keystore}
            if ref $response->{keystoreContent} eq 'HASH';
    }
    elsif ($format eq 'PKCS12') {
        $text = $response->{pkcs12Content}{pkcs12}
            if ref $response->{pkcs12Content} eq 'HASH';

        $text //= $response->{keystoreContent}{keystore}
            if ref $response->{keystoreContent} eq 'HASH';
    }
    elsif ($format eq 'PEM') {
        $text = $response->{certificateContent}{certificate}
            if ref $response->{certificateContent} eq 'HASH';

        $text //= $response->{keystoreContent}{keystore}
            if ref $response->{keystoreContent} eq 'HASH';
    }
    else {
        croak "Unsupported keystore format: $format";
    }

    croak "Download response did not contain content for format $format"
        unless defined $text && length $text;

    return $text;
}


# -------------------------------------------------------------------------
# Decode downloaded content.
#
# The API returns base64 strings for binary content. This helper is tolerant
# of PEM-style armor as well:
#
#   -----BEGIN CERTIFICATE-----
#   ...
#   -----END CERTIFICATE-----
#
# It strips BEGIN/END lines and whitespace before base64 decoding.
# -------------------------------------------------------------------------

sub extract_download_bytes {
    my ($self, $response, $format) = @_;

    my $text = $self->_download_base64_text($response, $format);

    $text =~ s/^-----BEGIN [^-]+-----\s*//mg;
    $text =~ s/^-----END [^-]+-----\s*//mg;
    $text =~ s/\s+//g;

    croak 'No base64 content remained after stripping armor/whitespace'
        unless length $text;

    my $bytes = decode_base64($text);

    croak 'Base64 decode produced no bytes'
        unless defined $bytes && length $bytes;

    return $bytes;
}


# -------------------------------------------------------------------------
# Download and save decoded binary content to disk.
#
# For JKS and PKCS12 this writes binary keystore data.
# For PEM, this currently writes decoded bytes as requested: "base64 armour
# removed". If callers want textual PEM output later, add a separate method
# that preserves PEM armor.
#
# The output file is created with mode 0600 because it contains private key
# material.
# -------------------------------------------------------------------------

sub download_and_save {
    my ($self, %args) = @_;

    my $output_file = $args{output_file};
    croak 'download_and_save requires output_file'
        unless defined $output_file && length $output_file;

    my $format   = uc($args{keystore_format} // 'JKS');
    my $response = $self->download_keystore(%args);
    my $bytes    = $self->extract_download_bytes($response, $format);

    sysopen my $fh, $output_file, O_WRONLY | O_CREAT | O_TRUNC, 0600
        or croak "Cannot open $output_file for writing: $!";
    binmode $fh, ':raw'
        or croak "Cannot set raw mode on $output_file: $!";

    print {$fh} $bytes
        or croak "Cannot write $output_file: $!";

    close $fh
        or croak "Cannot close $output_file: $!";

    return {
        output_file   => $output_file,
        bytes_written => length($bytes),
        response      => $response,
    };
}


1;

__END__

=head1 NAME

CertHub::Client - Small Moo/Mojo::UserAgent client for CertHub certificate requests

=head1 SYNOPSIS

    use CertHub::Client;

    # Basic auth (development/UAT): pass auth_mode => 'basic' with username/password.
    # OAuth (production): pass auth_mode => 'oauth' with token_url/username/password.
    my $ch = CertHub::Client->new(
        base_url      => 'https://certhub.example.com',
        auth_mode     => 'basic',
        username      => $api_user,
        password      => $api_pass,
        ca_file       => '/etc/pki/tls/certs/ca-bundle.pem',
        nar_id        => '000000-0',
        platform      => 'DAP',
        email_contact => 'CertificateManagement@example.com',
    );

    my $validation_payload = $ch->build_validation_payload(
        host => 'myhost.example.com',
    );

    my $validation = $ch->validate_cert_request($validation_payload);

    die "Validation failed\n" if $ch->has_validation_errors($validation);

    my $newcert_payload = $ch->build_newcert_payload(
        host              => 'myhost.example.com',
        download_password => $download_password,
    );

    my $newcert = $ch->onboard_newcert($newcert_payload);

    # Poll the "onboard cert request" task — this is the one that transitions
    # to "done" when the certificate is issued (~10-15 seconds).
    # Do NOT poll the "provide results" task; that is a later notification task.
    my $cms_task_id = $ch->onboard_task_id
        or die "No onboard cert request task found\n";

    my $poll = $ch->poll_task_until_final(
        cms_ref  => $cms_task_id,
        task_id  => $cms_task_id,
        interval => 5,
        timeout  => 300,
        on_state => sub {
            my ($state, $response) = @_;
            print "Current state: $state\n";
        },
    );

    unless ($poll->{success}) {
        my $detail = $ch->task_failure_summary($poll->{response}, $cms_task_id);
        die "Certificate generation failed ($poll->{state}): $detail\n";
    }

    $ch->download_and_save(
        client_ref      => $ch->last_client_ref,
        cms_task_id     => $cms_task_id,
        password        => $download_password,
        keystore_format => 'JKS',
        alias           => 'myhost.example.com',
        output_file     => 'myhost.example.com.jks',
    );

=head1 DESCRIPTION

This module implements the CertHub flow described by the supplied design:

  validate cert request
  onboard new cert
  poll task result
  download JKS/PKCS12/PEM content
  base64-decode and save

It uses Moo for object orientation and Mojo::UserAgent for HTTP.

For TLS behavior:
- modern per-UA insecure support is described in F<Mojo::UserAgent.txt>
- old Mojo limitations are described in F<old_Mojo::UserAgent.txt>
- the fallback TLS-disable mechanism is based on F<IO::Socket::SSL.txt>

The compatibility guidance and bug notes applied here are the ones called
out in F<out.txt>, and this file updates the original implementation from
F<CertHub/Client.pm> accordingly.

=cut
package CertHub::Client;

use Data::Dumper;
use JSON;

use strict;
use warnings;

# Moo gives us a small, modern OO layer without pulling in full Moose.
use Moo;

# Carp is used for caller-friendly exceptions.
use Carp qw(croak);

# Mojo::UserAgent is the required HTTP client for this module.
use Mojo::UserAgent;

# Mojo::URL is useful for safe URL/query manipulation.
use Mojo::URL;

# Mojo::Util provides URL escaping and base64 helpers for HTTP Basic auth.
use Mojo::Util qw(url_escape b64_encode);

# MIME::Base64 is used for decoding downloaded certificate/keystore content.
use MIME::Base64 qw(decode_base64);

# Time::HiRes allows fractional sleeps and reliable timeout calculations.
use Time::HiRes qw(time sleep);

# POSIX is used for a compact UTC timestamp in generated clientRef values.
use POSIX qw(strftime);

# Bytes from /dev/urandom are used for uniqueness in generated clientRef values.
use Fcntl qw(:DEFAULT);

# Needed for old-Mojo insecure fallback via IO::Socket::SSL.
use IO::Socket::SSL qw(SSL_VERIFY_NONE);


our $VERSION = '0.01';

my $OLD_MOJO_INSECURE_HACK_INSTALLED;


# -------------------------------------------------------------------------
# Constructor attributes: connection/authentication.
# -------------------------------------------------------------------------

has base_url => (
    is       => 'ro',
    required => 1,
);

has token_url => (
    is => 'ro',
);

has username => (
    is => 'ro',
);

has password => (
    is => 'ro',
);

has access_token => (
    is        => 'rw',
    predicate => 'has_access_token',
);

has auth_mode => (
    is      => 'ro',
    default => sub { 'oauth' },  # 'oauth' or 'basic'
);

has ca_file => (
    is => 'ro',
);

has insecure => (
    is      => 'ro',
    default => sub { 0 },
);

has connect_timeout => (
    is      => 'ro',
    default => sub { 30 },
);

has request_timeout => (
    is      => 'ro',
    default => sub { 120 },
);


# -------------------------------------------------------------------------
# Constructor attributes: useful payload defaults.
#
# These values are not required for the low-level request methods, but they
# make the helper payload builders much easier to use from small scripts.
# -------------------------------------------------------------------------

has nar_id => (
    is => 'ro',
);

has platform => (
    is      => 'ro',
    default => sub { 'DAP' },
);

has email_contact => (
    is => 'ro',
);

has change_id => (
    is => 'ro',
);

has cert_type => (
    is      => 'ro',
    default => sub { 'gcp-ssl-client-server' },
);

has organisation => (
    is      => 'ro',
    default => sub { 'Deutsche Bank AG' },
);

has org_unit => (
    is      => 'ro',
    default => sub { 'PKI' },
);


# -------------------------------------------------------------------------
# Lifecycle attributes: populated after onboard_newcert() is called.
# These let callers (and polling loops) refer back to the submitted request
# without needing to parse the response themselves.
# -------------------------------------------------------------------------

has last_client_ref => (
    is => 'rw',
);

has onboard_task_id => (
    is => 'rw',
);

has provide_results_task_id => (
    is => 'rw',
);


# -------------------------------------------------------------------------
# Old Mojo insecure compatibility.
#
# According to old_Mojo::UserAgent.txt, very old Mojo::UserAgent has neither
# the modern "insecure" attribute nor "tls_options". According to
# IO::Socket::SSL.txt, disabling verification can be forced with:
#
#   SSL_verify_mode     => SSL_VERIFY_NONE
#   SSL_verifycn_scheme => 'none'
#
# set_args_filter_hack is process-global, so this is only an emergency
# fallback for old Mojo installs.
# -------------------------------------------------------------------------

sub _install_old_mojo_insecure_hack {
    return if $OLD_MOJO_INSECURE_HACK_INSTALLED++;

    IO::Socket::SSL::set_args_filter_hack(sub {
        my ($is_server, $args) = @_;

        # Only weaken client-side TLS.
        return if $is_server;

        # curl -k equivalent: disable chain and hostname verification.
        $args->{SSL_verify_mode}     = SSL_VERIFY_NONE;
        $args->{SSL_verifycn_scheme} = 'none';

        # Remove other verification-related settings that could interfere.
        delete @{$args}{qw(
            SSL_ca
            SSL_ca_file
            SSL_ca_path
            SSL_verify_callback
            SSL_fingerprint
            SSL_force_fingerprint
        )};
    });

    return;
}


# -------------------------------------------------------------------------
# Lazy Mojo::UserAgent construction.
#
# This centralizes TLS and timeout configuration. In modern Mojo,
# Mojo::UserAgent.txt documents "insecure" and "tls_options". In old Mojo,
# old_Mojo::UserAgent.txt shows neither, so we must fall back to
# IO::Socket::SSL::set_args_filter_hack as documented in IO::Socket::SSL.txt.
# -------------------------------------------------------------------------

has ua => (
    is      => 'lazy',
    builder => '_build_ua',
);

sub _build_ua {
    my ($self) = @_;

    my $ua = Mojo::UserAgent->new;

    $ua->connect_timeout($self->connect_timeout);
    $ua->request_timeout($self->request_timeout);

    if ($self->insecure) {
        if ($ua->can('insecure')) {
            # Modern Mojo::UserAgent, per Mojo::UserAgent.txt
            $ua->insecure(1);
        }
        elsif ($ua->can('tls_options')) {
            # Intermediate Mojo with tls_options support
            $ua->tls_options({
                SSL_verify_mode     => SSL_VERIFY_NONE,
                SSL_verifycn_scheme => 'none',
            });
        }
        else {
            # Very old Mojo::UserAgent, per old_Mojo::UserAgent.txt
            _install_old_mojo_insecure_hack();
        }
    }
    elsif (defined $self->ca_file && length $self->ca_file) {
        # In old_Mojo::UserAgent.txt, "ca" is used for peer verification and
        # also activates hostname verification, so only set it when not using
        # insecure mode.
        $ua->ca($self->ca_file);
    }

    return $ua;
}


# -------------------------------------------------------------------------
# URL helper.
#
# certomato.py configures a base URL ending in /certhub/v1/ and then appends
# endpoint paths such as certhub/api/application/onboard/newcert.
#
# This helper keeps that convention and avoids accidental double slashes.
# -------------------------------------------------------------------------

sub _url_for {
    my ($self, $path) = @_;

    croak 'path is required' unless defined $path && length $path;

    my $base = $self->base_url;
    $base =~ s{/+\z}{};

    $path =~ s{\A/+}{};

    return "$base/$path";
}


# -------------------------------------------------------------------------
# Response helper.
#
# old_Mojo::UserAgent.txt prominently shows transaction-level error handling
# using $tx->success / $tx->error. Modern Mojo::UserAgent.txt uses $tx->result
# more heavily. This helper stays friendly to older Mojo by checking
# transaction-level errors first, then returning a response object.
# -------------------------------------------------------------------------

sub _checked_res {
    my ($self, $tx, $context) = @_;

    croak "$context failed: no transaction returned"
        unless defined $tx;

    # Modern Mojo: $tx->result croaks on connection errors and returns the
    # response (even 4xx/5xx) otherwise. Use it when available.
    if ($tx->can('result')) {
        my $res = $tx->result;

        if (my $err = $res->error) {
            my $status = $err->{code} // 'transport';
            my $msg    = $err->{message} // 'unknown error';
            croak "$context failed: status=$status message=$msg body=" . ($res->body // '');
        }

        return $res;
    }

    # Old Mojo: $tx->success returns undef on any error.
    if ($tx->can('success')) {
        if (my $res = $tx->success) {
            return $res;
        }

        my $err    = $tx->error || {};
        my $status = $err->{code} // 'transport';
        my $msg    = $err->{message} // 'unknown error';
        my $body   = ($tx->can('res') && $tx->res) ? ($tx->res->body // '') : '';

        croak "$context failed: status=$status message=$msg body=$body";
    }

    croak "$context failed: unsupported Mojo::Transaction object";
}


# -------------------------------------------------------------------------
# OAuth2 client-credentials authentication.
#
# If the caller supplied access_token, use it directly. Otherwise request a
# token using the same basic idea as certomato.py:
#
#   POST token_url
#   HTTP Basic auth username:password
#   form grant_type=client_credentials
#
# The token is retained on the object for subsequent API calls.
# -------------------------------------------------------------------------

sub authenticate {
    my ($self) = @_;

    return $self->access_token if $self->has_access_token && length $self->access_token;

    for my $field (qw(token_url username password)) {
        my $value = $self->$field;
        croak "Cannot authenticate: $field is required unless access_token is supplied"
            unless defined $value && length $value;
    }

    my $basic = b64_encode($self->username . ':' . $self->password, '');

    my $tx = $self->ua->post(
        $self->token_url
            => {
                Authorization => "Basic $basic",
                Accept        => 'application/json',
            }
            => form => {
                grant_type => 'client_credentials',
            }
    );

    my $res = $self->_checked_res($tx, 'Token request');

    my $json = $res->json;
    croak 'Token response was not valid JSON'
        unless defined $json && ref $json eq 'HASH';

    my $token = $json->{access_token};
    croak 'Token response did not contain access_token'
        unless defined $token && length $token;

    $self->access_token($token);

    return $token;
}


# -------------------------------------------------------------------------
# Authentication header helper.
#
# Selects Basic or Bearer auth based on the auth_mode attribute.
# auth_mode 'basic' uses the username/password attributes directly.
# auth_mode 'oauth' (default) calls authenticate() to obtain a bearer token.
# -------------------------------------------------------------------------

sub _auth_header {
    my ($self) = @_;

    my $mode = lc($self->auth_mode // 'oauth');

    if ($mode eq 'basic') {
        for my $field (qw(username password)) {
            croak "Basic auth requires $field"
                unless defined $self->$field && length $self->$field;
        }
        my $credentials = b64_encode($self->username . ':' . $self->password, '');
        return "Basic $credentials";
    }

    if ($mode eq 'oauth') {
        my $token = $self->authenticate;
        return "Bearer $token";
    }

    croak "Unsupported auth_mode: $mode";
}


# -------------------------------------------------------------------------
# Generic JSON request helper.
#
# All normal CertHub calls go through this method. It:
#
#   * selects auth via _auth_header (Basic or OAuth Bearer);
#   * constructs the full URL;
#   * sends JSON for POST requests;
#   * sends query parameters for GET requests if payload is supplied;
#   * throws detailed errors including the response body;
#   * returns decoded JSON.
# -------------------------------------------------------------------------

sub request_json {
    my ($self, $method, $path, $payload) = @_;

    croak 'HTTP method is required' unless defined $method && length $method;
    croak 'path is required'        unless defined $path   && length $path;

    $method  = uc $method;
    $payload = {} unless defined $payload;

    my $url = $self->_url_for($path);

    my $headers = {
        Accept        => 'application/json',
        Authorization => $self->_auth_header,
    };

    my $tx;

    if ($method eq 'POST') {
        $tx = $self->ua->post($url => $headers => json => $payload);
    }
    elsif ($method eq 'GET') {
        my $u = Mojo::URL->new($url);

        if (ref $payload eq 'HASH' && keys %{$payload}) {
            $u->query($payload);
        }

        $tx = $self->ua->get($u => $headers);
    }
    else {
        croak "Unsupported HTTP method: $method";
    }

    my $res = $self->_checked_res($tx, "$method $url");

    my $json = $res->json;

    croak "$method $url returned non-JSON response: " . ($res->body // '')
        unless defined $json;

    return $json;
}

# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   POST /certhub/api/validate/certrequest
#
# This maps to CertRequestValidationReq in openapi.json.
# -------------------------------------------------------------------------

sub validate_cert_request {
    my ($self, $payload) = @_;

    croak 'validate_cert_request requires a payload hashref'
        unless ref $payload eq 'HASH';

    return $self->request_json(
        POST => 'certhub/api/validate/certrequest',
        $payload,
    );
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   POST /certhub/api/application/onboard/newcert
#
# This maps to OnboardingReqNewCert in openapi.json and to create_certificate()
# in certomato.py.
# -------------------------------------------------------------------------

sub onboard_newcert {
    my ($self, $payload) = @_;

    croak 'onboard_newcert requires a payload hashref'
        unless ref $payload eq 'HASH';

    # Save clientRef from the payload before the HTTP call so it is available
    # for recovery even if the request times out after the server accepted it.
    $self->last_client_ref($payload->{clientRef})
        if defined $payload->{clientRef} && length $payload->{clientRef};

    # Clear stale lifecycle state from any previous request on this object.
    $self->onboard_task_id(undef);
    $self->provide_results_task_id(undef);

    my $response = $self->request_json(
        POST => 'certhub/api/application/onboard/newcert',
        $payload,
    );

    # Update from response (authoritative); payload value is the fallback.
    $self->last_client_ref($response->{clientRef})
        if defined $response->{clientRef} && length $response->{clientRef};

    $self->onboard_task_id($self->find_onboard_task_id($response));
    $self->provide_results_task_id($self->find_provide_results_task_id($response));

    return $response;
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   GET /certhub/task/result/{cmsRef}
#
# In the intended workflow, cmsRef is the id of the "onboard cert request"
# task. Poll this task to know when the certificate has been issued.
# The "provide results" task is a later notification task (~5 min) and is
# not needed to confirm certificate issuance.
# -------------------------------------------------------------------------

sub task_result {
    my ($self, $cms_ref) = @_;

    croak 'task_result requires cms_ref'
        unless defined $cms_ref && length $cms_ref;

    return $self->request_json(
        GET => 'certhub/task/result/' . url_escape($cms_ref),
    );
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   GET /certhub/task/resultByClientRef/{clientRef}
#
# This is useful for recovery/debugging if a caller has clientRef but not the
# task id.
# -------------------------------------------------------------------------

sub task_result_by_client_ref {
    my ($self, $client_ref) = @_;

    croak 'task_result_by_client_ref requires client_ref'
        unless defined $client_ref && length $client_ref;

    return $self->request_json(
        GET => 'certhub/task/resultByClientRef/' . url_escape($client_ref),
    );
}


# -------------------------------------------------------------------------
# Low-level endpoint wrapper:
#
#   POST /certhub/api/request/download
#
# This endpoint is documented in openapi.json and discussed in download_cert.ans
# and download_cert2.ans. certomato.py does not currently wrap it.
# -------------------------------------------------------------------------

sub download {
    my ($self, $payload) = @_;

    croak 'download requires a payload hashref'
        unless ref $payload eq 'HASH';

    return $self->request_json(
        POST => 'certhub/api/request/download',
        $payload,
    );
}


# -------------------------------------------------------------------------
# Useful extra endpoint:
#
#   POST /certhub/api/validate/domain
#
# certomato.py has this, and what_I_need_to_do.ans discusses it as a useful
# pre-check.
# -------------------------------------------------------------------------

sub validate_domain {
    my ($self, $nar_id, $domains) = @_;

    croak 'validate_domain requires nar_id'
        unless defined $nar_id && length $nar_id;

    croak 'validate_domain requires domains arrayref'
        unless ref $domains eq 'ARRAY';

    return $self->request_json(
        POST => 'certhub/api/validate/domain',
        {
            narId   => $nar_id,
            domains => $domains,
        },
    );
}


# -------------------------------------------------------------------------
# Useful extra endpoint:
#
#   POST /certhub/api/application/certificates
#
# This mirrors get_certificates() in certomato.py at a low level.
# -------------------------------------------------------------------------

sub query_certificates {
    my ($self, $payload) = @_;

    $payload = {} unless defined $payload;

    croak 'query_certificates payload must be a hashref'
        unless ref $payload eq 'HASH';

    return $self->request_json(
        POST => 'certhub/api/application/certificates',
        $payload,
    );
}


# -------------------------------------------------------------------------
# Metadata builder.
#
# This returns a MetaData structure as defined in openapi.json. Undefined
# values are omitted, because some fields such as changeId may not always be
# required in all environments.
# -------------------------------------------------------------------------

sub build_metadata {
    my ($self, %args) = @_;

    my %md;

    my $nar_id        = exists $args{nar_id}        ? $args{nar_id}        : $self->nar_id;
    my $email_contact = exists $args{email_contact} ? $args{email_contact} : $self->email_contact;
    my $platform      = exists $args{platform}      ? $args{platform}      : $self->platform;

    $md{narId}        = $nar_id        if defined $nar_id        && length $nar_id;
    $md{emailContact} = $email_contact if defined $email_contact && length $email_contact;
    $md{platform}     = $platform      if defined $platform      && length $platform;

    # changeId is a top-level payload field, not a metaData field. See
    # build_newcert_payload. Do not add it here.

    return \%md;
}


# -------------------------------------------------------------------------
# Validation payload builder.
#
# openapi.json documents CertRequestValidationReq as:
#
#   {
#     certType:   string,
#     parameters: map<string,array<string>>,
#     metaData:   MetaData
#   }
#
# The exact parameters can be profile-specific, so callers can pass their own
# parameters hashref. If not supplied, this helper creates a sensible minimal
# payload with commonName and dns values.
# -------------------------------------------------------------------------

sub build_validation_payload {
    my ($self, %args) = @_;

    my $host = $args{host};
    croak 'build_validation_payload requires host'
        unless defined $host && length $host;

    my $cert_type = $args{cert_type} // $self->cert_type;
    my $cn        = $args{common_name} // $host;
    my $sans      = $args{sans} // [$host];

    croak 'sans must be an arrayref'
        unless ref $sans eq 'ARRAY';

    my $parameters = $args{parameters};

    if (defined $parameters) {
        croak 'parameters must be a hashref'
            unless ref $parameters eq 'HASH';
    }
    else {
        $parameters = {
            commonName => [$cn],
            dns        => $sans,
        };
    }

    return {
        certType   => $cert_type,
        parameters => $parameters,
        metaData   => $self->build_metadata(%args),
    };
}


# -------------------------------------------------------------------------
# New certificate onboarding payload builder.
#
# This builds the payload for:
#
#   POST /certhub/api/application/onboard/newcert
#
# It is oriented toward the download-only flow requested in what_I_need_to_do.ask:
# the generated key/cert should later be downloaded via API, not pushed by SSH.
#
# Therefore deploymentLocations is omitted unless explicitly supplied.
# -------------------------------------------------------------------------

sub build_newcert_payload {
    my ($self, %args) = @_;

    my $host = $args{host};
    croak 'build_newcert_payload requires host'
        unless defined $host && length $host;

    my $download_password = $args{download_password};
    croak 'build_newcert_payload requires download_password'
        unless defined $download_password && length $download_password;

    my $client_ref = $args{client_ref} // $self->generate_client_ref($host);

    my $cert_type = $args{cert_type}   // $self->cert_type;
    my $cn        = $args{common_name} // $host;
    my $sans      = $args{sans}        // [$host];

    croak 'sans must be an arrayref'
        unless ref $sans eq 'ARRAY';

    my $cert_request = {
        certType         => $cert_type,
        commonName       => $cn,
        o                => $args{organisation} // $self->organisation,
        ou               => $args{org_unit}     // $self->org_unit,
        sans             => {
            dns => $sans,
        },
        metaData         => $self->build_metadata(%args),
        downloadPassword => $download_password,
    };

    if (defined $args{task_ref} && length $args{task_ref}) {
        $cert_request->{taskRef} = $args{task_ref};
    }

    if (exists $args{deployment_locations}) {
        croak 'deployment_locations must be an arrayref'
            unless ref $args{deployment_locations} eq 'ARRAY';

        $cert_request->{deploymentLocations} = $args{deployment_locations};
    }

    my $payload = {
        clientRef              => $client_ref,
        platform               => $args{platform} // $self->platform,
        onboardingCertRequests => [$cert_request],
    };

    my $change_id = exists $args{change_id} ? $args{change_id} : $self->change_id;

    if (defined $change_id && length $change_id) {
        $payload->{changeId} = $change_id;
    }

    return $payload;
}


# -------------------------------------------------------------------------
# Client reference generator.
#
# certomato.py generates unique clientRef strings using a prefix, date, and
# random bytes. This does the same kind of thing in Perl.
# -------------------------------------------------------------------------

sub generate_client_ref {
    my ($self, $host) = @_;

    $host //= 'unknown-host';
    $host =~ s/[^A-Za-z0-9_.:-]+/-/g;

    my $ts  = strftime('%Y%m%d%H%M%S', gmtime);
    my $rnd = _random_hex(4);

    return "cert-request-$host-$ts-$rnd";
}


# -------------------------------------------------------------------------
# Small internal random hex helper.
# -------------------------------------------------------------------------

sub _random_hex {
    my ($nbytes) = @_;

    $nbytes ||= 4;

    my $buf = '';

    if (sysopen my $fh, '/dev/urandom', O_RDONLY) {
        my $read = sysread $fh, $buf, $nbytes;
        close $fh;

        if (defined $read && $read == $nbytes) {
            return unpack('H*', $buf);
        }
    }

    # Fallback for unusual systems without /dev/urandom.
    return sprintf '%08x', int(rand(0xffffffff));
}


# -------------------------------------------------------------------------
# Validation response helper.
#
# CertHub validation responses contain validationResults, a map of arrays of
# FieldValidationError objects. This helper returns the ones that are marked
# as errors.
# -------------------------------------------------------------------------

sub validation_errors {
    my ($self, $validation_response) = @_;

    return [] unless ref $validation_response eq 'HASH';

    my $vr = $validation_response->{validationResults};
    return [] unless ref $vr eq 'HASH';

    my @errors;

    for my $field (sort keys %{$vr}) {
        my $items = $vr->{$field};
        next unless ref $items eq 'ARRAY';

        for my $item (@{$items}) {
            next unless ref $item eq 'HASH';

            if ($item->{error}) {
                push @errors, {
                    field   => $field,
                    code    => $item->{code},
                    message => $item->{message},
                    raw     => $item,
                };
            }
        }
    }

    return \@errors;
}


# -------------------------------------------------------------------------
# Boolean validation helper.
# -------------------------------------------------------------------------

sub has_validation_errors {
    my ($self, $validation_response) = @_;

    return @{ $self->validation_errors($validation_response) } ? 1 : 0;
}


# -------------------------------------------------------------------------
# Task helper: find a CMS task by action regex.
#
# The newcert response is a SnowResp according to openapi.json, containing
# cmsTasks. design specifically says to find the id of the task whose action
# is "provide results".
# -------------------------------------------------------------------------

sub find_task_id_by_action {
    my ($self, $snow_response, $regex) = @_;

    croak 'find_task_id_by_action requires a response hashref'
        unless ref $snow_response eq 'HASH';

    croak 'find_task_id_by_action requires a regex'
        unless ref $regex eq 'Regexp';

    my $tasks = $snow_response->{cmsTasks} // [];
    croak 'cmsTasks must be an arrayref'
        unless ref $tasks eq 'ARRAY';

    for my $task (@{$tasks}) {
        next unless ref $task eq 'HASH';

        my $action = $task->{action} // '';

        if ($action =~ $regex) {
            return $task->{id};
        }
    }

    return undef;
}


# -------------------------------------------------------------------------
# Task helper: find the "onboard cert request" task id.
# -------------------------------------------------------------------------

sub find_onboard_task_id {
    my ($self, $snow_response) = @_;

    return $self->find_task_id_by_action(
        $snow_response,
        qr/\bonboard\s+cert\s+request\b/i,
    );
}


# -------------------------------------------------------------------------
# Task helper: find the "provide results" task id.
# -------------------------------------------------------------------------

sub find_provide_results_task_id {
    my ($self, $snow_response) = @_;

    return $self->find_task_id_by_action(
        $snow_response,
        qr/\bprovide\s+results\b/i,
    );
}


# -------------------------------------------------------------------------
# Task helper: return normalized task state information.
#
# This is intentionally simple so monitor_cert_generation can print it easily.
# -------------------------------------------------------------------------

sub task_states {
    my ($self, $snow_response) = @_;

    return [] unless ref $snow_response eq 'HASH';

    my $tasks = $snow_response->{cmsTasks} // [];
    return [] unless ref $tasks eq 'ARRAY';

    my @states;

    for my $task (@{$tasks}) {
        next unless ref $task eq 'HASH';

        push @states, {
            id     => $task->{id},
            action => $task->{action},
            state  => $task->{state},
        };
    }

    return \@states;
}


# -------------------------------------------------------------------------
# Task helper: get state for a specific task id.
#
# If the response only contains one task, this method falls back to that task.
# That makes it tolerant of both detailed and narrowed task-result responses.
# -------------------------------------------------------------------------

sub state_for_task {
    my ($self, $snow_response, $task_id) = @_;

    return undef unless ref $snow_response eq 'HASH';

    my $tasks = $snow_response->{cmsTasks} // [];
    return undef unless ref $tasks eq 'ARRAY';

    if (defined $task_id) {
        for my $task (@{$tasks}) {
            next unless ref $task eq 'HASH';

            return $task->{state}
                if defined $task->{id} && "$task->{id}" eq "$task_id";
        }
    }

    if (@{$tasks} == 1 && ref $tasks->[0] eq 'HASH') {
        return $tasks->[0]{state};
    }

    return undef;
}


# -------------------------------------------------------------------------
# Poll task until final state.
#
# This implements the monitoring behaviour requested in design:
# keep checking state until it is no longer init, then return success/failure.
#
# Callers can pass on_state => sub { my ($state, $response) = @_; ... }
# to print progress while polling.
# -------------------------------------------------------------------------

sub poll_task_until_final {
    my ($self, %args) = @_;

    my $cms_ref = $args{cms_ref};
    croak 'poll_task_until_final requires cms_ref'
        unless defined $cms_ref && length $cms_ref;

    my $task_id  = $args{task_id};
    my $interval = $args{interval} // 30;
    my $timeout  = $args{timeout}  // 1800;

    my $on_state = $args{on_state};

    croak 'on_state must be a coderef if supplied'
        if defined $on_state && ref $on_state ne 'CODE';

    my %init_states = map { lc($_) => 1 } @{ $args{init_states} // [qw(init running)] };

    my %success_states = map { lc($_) => 1 } @{ $args{success_states} // ['done'] };

    my %failure_states = map { lc($_) => 1 } @{ $args{failure_states} // [qw(failed failure error cancelled canceled)] };

    my $deadline = time + $timeout;

    while (1) {
        my $response = $self->task_result($cms_ref);
        my $state    = $self->state_for_task($response, $task_id);

        $state = 'unknown' unless defined $state && length $state;

        $on_state->($state, $response) if $on_state;

        my $norm = lc $state;

        if ($success_states{$norm}) {
            return {
                success  => 1,
                state    => $state,
                response => $response,
            };
        }

        if ($failure_states{$norm}) {
            return {
                success  => 0,
                state    => $state,
                response => $response,
            };
        }

        if (!$init_states{$norm}) {
            return {
                success  => 0,
                state    => $state,
                response => $response,
            };
        }

        my $remaining = $deadline - time;

        croak "Timed out after $timeout seconds waiting for task $cms_ref"
            if $remaining <= 0;

        sleep($interval < $remaining ? $interval : $remaining);
    }
}


# -------------------------------------------------------------------------
# Task helper: extract cmsActions from a task result response.
#
# Returns an arrayref of hashrefs with keys: action, state, result.
# If task_id is supplied, only actions for that task are returned.
# -------------------------------------------------------------------------

sub task_action_results {
    my ($self, $snow_response, $task_id) = @_;

    return [] unless ref $snow_response eq 'HASH';

    my $tasks = $snow_response->{cmsTasks};
    return [] unless ref $tasks eq 'ARRAY';

    my @results;

    for my $task (@{$tasks}) {
        next unless ref $task eq 'HASH';

        if (defined $task_id) {
            next unless defined $task->{id} && "$task->{id}" eq "$task_id";
        }

        my $actions = $task->{cmsActions};
        next unless ref $actions eq 'ARRAY';

        for my $action (@{$actions}) {
            next unless ref $action eq 'HASH';

            push @results, {
                action => $action->{action},
                state  => $action->{state},
                result => $action->{result},
            };
        }
    }

    return \@results;
}


# -------------------------------------------------------------------------
# Task helper: build a human-readable failure summary from cmsActions.
#
# Returns a string like "validate_metaData[failed]: ERROR:[Unknown NAR-ID]"
# suitable for appending to an error message.
# -------------------------------------------------------------------------

sub task_failure_summary {
    my ($self, $snow_response, $task_id) = @_;

    my $results = $self->task_action_results($snow_response, $task_id);

    my @lines = map {
        sprintf '%s[%s]: %s',
            $_->{action} // '(unknown)',
            $_->{state}  // '(unknown)',
            $_->{result} // ''
    } @{$results};

    return join '; ', grep { length } @lines;
}


# -------------------------------------------------------------------------
# Build CertDownloadReq.
#
# This is the JSON clarified in download_cert2.ans:
#
#   {
#     clientRef:      "...",
#     cmsTaskId:      123456,
#     password:       "...",
#     keystoreFormat: "JKS",
#     alias:          "hostname"
#   }
# -------------------------------------------------------------------------

sub build_download_payload {
    my ($self, %args) = @_;

    for my $field (qw(client_ref cms_task_id password)) {
        croak "build_download_payload requires $field"
            unless defined $args{$field} && length $args{$field};
    }

    croak 'cms_task_id must be a positive integer'
        unless $args{cms_task_id} =~ /\A[1-9]\d*\z/;

    my $format = uc($args{keystore_format} // 'JKS');

    croak "Unsupported keystore_format: $format"
        unless $format =~ /\A(?:JKS|PKCS12|PEM)\z/;

    my $payload = {
        clientRef      => $args{client_ref},
        cmsTaskId      => int($args{cms_task_id}),
        password       => $args{password},
        keystoreFormat => $format,
    };

    if (exists $args{aliases}) {
        croak 'aliases must be an arrayref'
            unless ref $args{aliases} eq 'ARRAY';
        if (@{ $args{aliases} }) {
            $payload->{alias} = @{ $args{aliases} } == 1
                ? $args{aliases}[0]
                : $args{aliases};
        }
    }
    elsif (defined $args{alias} && length $args{alias}) {
        $payload->{alias} = $args{alias};
    }

    return $payload;
}


# -------------------------------------------------------------------------
# Convenience method: build download payload and call the endpoint.
# -------------------------------------------------------------------------

sub download_keystore {
    my ($self, %args) = @_;

    my $payload = $self->build_download_payload(%args);

    return $self->download($payload);
}


# -------------------------------------------------------------------------
# Extract base64 text from a CertDownloadResp.
#
# openapi.json says the response can contain certificateContent,
# pkcs12Content, or keystoreContent. Which one is populated depends on
# requested format and backend behaviour.
# -------------------------------------------------------------------------

sub _download_base64_text {
    my ($self, $response, $format) = @_;

    croak 'download response must be a hashref'
        unless ref $response eq 'HASH';

    $format = uc($format // 'JKS');

    my $text;

    if ($format eq 'JKS') {
        $text = $response->{keystoreContent}{keystore}
            if ref $response->{keystoreContent} eq 'HASH';
    }
    elsif ($format eq 'PKCS12') {
        $text = $response->{pkcs12Content}{pkcs12}
            if ref $response->{pkcs12Content} eq 'HASH';

        $text //= $response->{keystoreContent}{keystore}
            if ref $response->{keystoreContent} eq 'HASH';
    }
    elsif ($format eq 'PEM') {
        $text = $response->{certificateContent}{certificate}
            if ref $response->{certificateContent} eq 'HASH';

        $text //= $response->{keystoreContent}{keystore}
            if ref $response->{keystoreContent} eq 'HASH';
    }
    else {
        croak "Unsupported keystore format: $format";
    }

    croak "Download response did not contain content for format $format"
        unless defined $text && length $text;

    return $text;
}


# -------------------------------------------------------------------------
# Decode downloaded content.
#
# The API returns base64 strings for binary content. This helper is tolerant
# of PEM-style armor as well:
#
#   -----BEGIN CERTIFICATE-----
#   ...
#   -----END CERTIFICATE-----
#
# It strips BEGIN/END lines and whitespace before base64 decoding.
# -------------------------------------------------------------------------

sub extract_download_bytes {
    my ($self, $response, $format) = @_;

    my $text = $self->_download_base64_text($response, $format);

    $text =~ s/^-----BEGIN [^-]+-----\s*//mg;
    $text =~ s/^-----END [^-]+-----\s*//mg;
    $text =~ s/\s+//g;

    croak 'No base64 content remained after stripping armor/whitespace'
        unless length $text;

    my $bytes = decode_base64($text);

    croak 'Base64 decode produced no bytes'
        unless defined $bytes && length $bytes;

    return $bytes;
}


# -------------------------------------------------------------------------
# Download and save decoded binary content to disk.
#
# For JKS and PKCS12 this writes binary keystore data.
# For PEM, this currently writes decoded bytes as requested: "base64 armour
# removed". If callers want textual PEM output later, add a separate method
# that preserves PEM armor.
#
# The output file is created with mode 0600 because it contains private key
# material.
# -------------------------------------------------------------------------

sub download_and_save {
    my ($self, %args) = @_;

    my $output_file = $args{output_file};
    croak 'download_and_save requires output_file'
        unless defined $output_file && length $output_file;

    my $format   = uc($args{keystore_format} // 'JKS');
    my $response = $self->download_keystore(%args);
    my $bytes    = $self->extract_download_bytes($response, $format);

    sysopen my $fh, $output_file, O_WRONLY | O_CREAT | O_TRUNC, 0600
        or croak "Cannot open $output_file for writing: $!";
    binmode $fh, ':raw'
        or croak "Cannot set raw mode on $output_file: $!";

    print {$fh} $bytes
        or croak "Cannot write $output_file: $!";

    close $fh
        or croak "Cannot close $output_file: $!";

    return {
        output_file   => $output_file,
        bytes_written => length($bytes),
        response      => $response,
    };
}


1;

__END__

=head1 NAME

CertHub::Client - Small Moo/Mojo::UserAgent client for CertHub certificate requests

=head1 SYNOPSIS

    use CertHub::Client;

    # Basic auth (development/UAT): pass auth_mode => 'basic' with username/password.
    # OAuth (production): pass auth_mode => 'oauth' with token_url/username/password.
    my $ch = CertHub::Client->new(
        base_url      => 'https://certhub.example.com',
        auth_mode     => 'basic',
        username      => $api_user,
        password      => $api_pass,
        ca_file       => '/etc/pki/tls/certs/ca-bundle.pem',
        nar_id        => '000000-0',
        platform      => 'DAP',
        email_contact => 'CertificateManagement@example.com',
    );

    my $validation_payload = $ch->build_validation_payload(
        host => 'myhost.example.com',
    );

    my $validation = $ch->validate_cert_request($validation_payload);

    die "Validation failed\n" if $ch->has_validation_errors($validation);

    my $newcert_payload = $ch->build_newcert_payload(
        host              => 'myhost.example.com',
        download_password => $download_password,
    );

    my $newcert = $ch->onboard_newcert($newcert_payload);

    # Poll the "onboard cert request" task — this is the one that transitions
    # to "done" when the certificate is issued (~10-15 seconds).
    # Do NOT poll the "provide results" task; that is a later notification task.
    my $cms_task_id = $ch->onboard_task_id
        or die "No onboard cert request task found\n";

    my $poll = $ch->poll_task_until_final(
        cms_ref  => $cms_task_id,
        task_id  => $cms_task_id,
        interval => 5,
        timeout  => 300,
        on_state => sub {
            my ($state, $response) = @_;
            print "Current state: $state\n";
        },
    );

    unless ($poll->{success}) {
        my $detail = $ch->task_failure_summary($poll->{response}, $cms_task_id);
        die "Certificate generation failed ($poll->{state}): $detail\n";
    }

    $ch->download_and_save(
        client_ref      => $ch->last_client_ref,
        cms_task_id     => $cms_task_id,
        password        => $download_password,
        keystore_format => 'JKS',
        alias           => 'myhost.example.com',
        output_file     => 'myhost.example.com.jks',
    );

=head1 DESCRIPTION

This module implements the CertHub flow described by the supplied design:

  validate cert request
  onboard new cert
  poll task result
  download JKS/PKCS12/PEM content
  base64-decode and save

It uses Moo for object orientation and Mojo::UserAgent for HTTP.

For TLS behavior:
- modern per-UA insecure support is described in F<Mojo::UserAgent.txt>
- old Mojo limitations are described in F<old_Mojo::UserAgent.txt>
- the fallback TLS-disable mechanism is based on F<IO::Socket::SSL.txt>

The compatibility guidance and bug notes applied here are the ones called
out in F<out.txt>, and this file updates the original implementation from
F<CertHub/Client.pm> accordingly.

=cut

cert_request.pl

#!/usr/bin/env perl

use strict;
use warnings;

use FindBin;
use lib $FindBin::Dir;

use CertHub::Client;

sub fail {
    my ($msg) = @_;
    chomp $msg;
    warn "ERROR: $msg\n";
    exit 1;
}

sub required_env {
    my ($name) = @_;
    my $value = $ENV{$name};
    fail("Missing $name") unless defined $value && length $value;
    return $value;
}

my $VERSION = '1.1.0';

eval { run() };
fail($@) if $@;

sub run {
    print "cert_request.pl v$VERSION\n";

    my $base_url    = required_env('BASE_URL');
    my $user        = required_env('USR');
    my $pass        = required_env('PW');
    my $host        = required_env('HOST');
    my $download_pw = required_env('DOWNLOAD_PW');
    my $nar_id      = required_env('NAR_ID');
    my $email       = required_env('EMAIL');
    my $cert_type   = $ENV{CERT_TYPE} // 'gcp-ssl-client-server';

    my $poll_interval = $ENV{POLL_INTERVAL} // 5;
    my $poll_timeout  = $ENV{POLL_TIMEOUT}  // 300;
    my $insecure      = ($ENV{INSECURE} ? 1 : 0);

    my $keystore_format = uc($ENV{KEYSTORE_FORMAT} // 'JKS');
    $keystore_format = 'PKCS12' if $keystore_format eq 'P12';
    fail('KEYSTORE_FORMAT must be one of JKS, PKCS12, PEM')
        unless $keystore_format =~ /\A(?:JKS|PKCS12|PEM)\z/;

    my %ext_map = (JKS => 'jks', PKCS12 => 'p12', PEM => 'pem');
    my $output_file = $ENV{OUTPUT_FILE} // ($host . '.' . $ext_map{$keystore_format});

    # Build the SAN list: HOST is always first; SANS adds extra hostnames.
    # Trim each entry and de-duplicate case-insensitively.
    my @extra_sans = map { s/\A\s+//; s/\s+\z//; $_ }
                     split /,/, ($ENV{SANS} // '');
    my %seen;
    my @sans = grep { length $_ && !$seen{lc $_}++ } ($host, @extra_sans);
    my $sans = \@sans;

    my $ch = CertHub::Client->new(
        base_url      => $base_url,
        auth_mode     => 'basic',
        username      => $user,
        password      => $pass,
        insecure      => $insecure,
        nar_id        => $nar_id,
        email_contact => $email,
        cert_type     => $cert_type,
    );

    printf "Submitting cert request for %s ...\n", join(', ', @$sans);

    my $payload = $ch->build_newcert_payload(
        host              => $host,
        sans              => $sans,
        download_password => $download_pw,
    );

    my $response = $ch->onboard_newcert($payload);

    my $task_id = $ch->onboard_task_id
        or die "Response contained no 'onboard cert request' task\n";

    printf "Submitted. clientRef=%s  onboard_task_id=%s\n",
        $ch->last_client_ref // '(unknown)',
        $task_id;

    print "Polling for completion...\n";

    my $poll = $ch->poll_task_until_final(
        cms_ref  => $task_id,
        task_id  => $task_id,
        interval => $poll_interval,
        timeout  => $poll_timeout,
        on_state => sub {
            my ($state) = @_;
            print "  state: $state\n";
        },
    );

    unless ($poll->{success}) {
        my $detail = $ch->task_failure_summary($poll->{response}, $task_id);
        my $msg = "Certificate generation ended with state '$poll->{state}'";
        $msg .= ": $detail" if length $detail;
        die "$msg\n";
    }

    print "Certificate generated. Downloading...\n";

    my $saved = $ch->download_and_save(
        client_ref      => $ch->last_client_ref,
        cms_task_id     => $task_id,
        password        => $download_pw,
        keystore_format => $keystore_format,
        alias           => $host,
        output_file     => $output_file,
    );

    printf "Saved %d bytes to %s\n", $saved->{bytes_written}, $saved->{output_file};
}
#!/usr/bin/env perl

use strict;
use warnings;

use FindBin;
use lib $FindBin::Dir;

use CertHub::Client;

sub fail {
    my ($msg) = @_;
    chomp $msg;
    warn "ERROR: $msg\n";
    exit 1;
}

sub required_env {
    my ($name) = @_;
    my $value = $ENV{$name};
    fail("Missing $name") unless defined $value && length $value;
    return $value;
}

my $VERSION = '1.1.0';

# eval/fail is a top-level catch-and-die. $@ is printed to stderr, which is
# the appropriate log channel for a CLI tool. Note that Carp::longmess or
# Devel::StackTrace could be used here if stack traces become necessary for
# diagnosis; for now the propagated croak message is sufficient.
eval { run() };
fail($@) if $@;

sub run {
    print "cert_request.pl v$VERSION\n";

    my $base_url    = required_env('BASE_URL');
    my $user        = required_env('USR');
    my $pass        = required_env('PW');
    my $host        = required_env('HOST');
    my $download_pw = required_env('DOWNLOAD_PW');
    my $nar_id      = required_env('NAR_ID');
    my $email       = required_env('EMAIL');
    my $cert_type   = $ENV{CERT_TYPE} // 'gcp-ssl-client-server';

    my $poll_interval = $ENV{POLL_INTERVAL} // 5;
    my $poll_timeout  = $ENV{POLL_TIMEOUT}  // 300;
    my $insecure      = ($ENV{INSECURE} ? 1 : 0);

    my $keystore_format = uc($ENV{KEYSTORE_FORMAT} // 'JKS');
    $keystore_format = 'PKCS12' if $keystore_format eq 'P12';
    fail('KEYSTORE_FORMAT must be one of JKS, PKCS12, PEM')
        unless $keystore_format =~ /\A(?:JKS|PKCS12|PEM)\z/;

    my %ext_map = (JKS => 'jks', PKCS12 => 'p12', PEM => 'pem');
    my $output_file = $ENV{OUTPUT_FILE} // ($host . '.' . $ext_map{$keystore_format});

    # Build the SAN list: HOST is always first; SANS adds extra hostnames.
    # Trim each entry and de-duplicate case-insensitively.
    my @extra_sans = map { s/\A\s+//; s/\s+\z//; $_ }
                     split /,/, ($ENV{SANS} // '');
    my %seen;
    my @sans = grep { length $_ && !$seen{lc $_}++ } ($host, @extra_sans);
    my $sans = \@sans;

    my $ch = CertHub::Client->new(
        base_url      => $base_url,
        auth_mode     => 'basic',
        username      => $user,
        password      => $pass,
        insecure      => $insecure,
        nar_id        => $nar_id,
        email_contact => $email,
        cert_type     => $cert_type,
    );

    printf "Submitting cert request for %s ...\n", join(', ', @$sans);

    my $payload = $ch->build_newcert_payload(
        host              => $host,
        sans              => $sans,
        download_password => $download_pw,
    );

    my $response = $ch->onboard_newcert($payload);

    my $task_id = $ch->onboard_task_id
        or die "Response contained no 'onboard cert request' task\n";

    printf "Submitted. clientRef=%s  onboard_task_id=%s\n",
        $ch->last_client_ref // '(unknown)',
        $task_id;

    print "Polling for completion...\n";

    my $poll = $ch->poll_task_until_final(
        cms_ref  => $task_id,
        task_id  => $task_id,
        interval => $poll_interval,
        timeout  => $poll_timeout,
        on_state => sub {
            my ($state) = @_;
            print "  state: $state\n";
        },
    );

    unless ($poll->{success}) {
        my $detail = $ch->task_failure_summary($poll->{response}, $task_id);
        my $msg = "Certificate generation ended with state '$poll->{state}'";
        $msg .= ": $detail" if length $detail;
        die "$msg\n";
    }

    print "Certificate generated. Downloading...\n";

    my $saved = $ch->download_and_save(
        client_ref      => $ch->last_client_ref,
        cms_task_id     => $task_id,
        password        => $download_pw,
        keystore_format => $keystore_format,
        alias           => $host,
        output_file     => $output_file,
    );

    printf "Saved %d bytes to %s\n", $saved->{bytes_written}, $saved->{output_file};
}

Minor 12 — Unused imports in Client.pm

Minor 13 — Empty strings accepted for required env vars — CLOSED

Closed. required_env() already checks both defined and length, so empty strings are correctly rejected. The deferred note was wrong.

BASE_URL= passes the // check. A proper env_required() helper that also rejects empty strings was not added; deferred as usage is controlled.

Design 11 — OAuth not reachable from cert_request.pl

Client.pm supports OAuth (auth_mode => oauth, token_url, username, password) but cert_request.pl has no TOKEN_URL / AUTH_MODE env-var path. Deferred until OAuth credentials are available for testing. When implementing: read AUTH_MODE env var (default basic); for oauth read TOKEN_URL, CLIENT_ID, CLIENT_SECRET; pass auth_mode/token_url/username/password to CertHub::Client->new.

Design (extra) — Lifecycle state fragile for concurrent use

A single Client object cannot safely manage two concurrent cert requests because onboard_task_id etc. are overwritten on each call. Suggested fix: add parse_onboard_response() helper returning structured data without mutating the object. Deferred — current usage is single-request per script invocation.

Design (extra) — PEM download saves decoded bytes not armoured text

download_and_save always base64-decodes. For PEM callers usually expect textual -----BEGIN CERTIFICATE----- output. Suggested: add preserve_pem_text => 1 option to extract_download_content(). Deferred until PEM format is actually needed.

updated 2026-05-27