NAME

WWW::Authentik::API - authentik REST API v3 with idempotent ensure methods

VERSION

version 0.001

SYNOPSIS

my $api = WWW::Authentik->new( base_url => $url, token => $token )->api;

# one call, one endpoint
my $user = $api->find_user('alice');
my $new  = $api->create_group( { name => 'staff' } );

# wanted state, as often as you like
my $r = $api->ensure_user( username => 'alice', name => 'Alice', group_names => ['staff'] );
print $r->{changed};        # 'created', 'updated' or ''
print $r->{object}{pk};

DESCRIPTION

authentik's REST API v3 for the whole instance. There is no realm; what a call reaches is decided by the API token.

The basic methods are one endpoint each. get_* and find_* return the representation as a hash (find_* returns nothing when there is no match), list_* an array reference with every match, create_* and update_* the representation authentik answered with, and delete_* true.

A list_* walks authentik's pagination for you, asking for "page_size" objects at a time and following pagination.next until it is 0. It stops when a page comes round a second time, so an answer whose next points backwards ends the walk instead of running for ever.

Writing is PATCH. authentik's PUT wants the required fields but leaves everything else alone, so it replaces nothing that PATCH would not; there is no reason to use it.

Every failure is a WWW::Authentik::Error::API. Note that a duplicate is 400 with a field error, not 409, and that a refused token is 403, not 401.

The ensure_* methods are what makes a setup repeatable. Each looks the object up by its readable key, creates it when it is missing, otherwise writes only what differs, and returns { object => \%rep, changed => 'created' | 'updated' | '' }. Only the keys given are compared; nothing is ever deleted.

Where authentik wants an identifier and a person knows a name, "resolve" looks it up. Which form counts as the identifier is fixed per field and never guessed; see there.

base_url

Required. The authentik URL without /api/v3.

token

Required. The API token, sent as a bearer token with every call.

ua

Required. The LWP::UserAgent to use.

page_size

How many objects a list_* asks for per request. Default 100.

uuid_pattern

integer_pattern

The two shapes "resolve" takes for an identifier rather than a name: a UUID and a run of digits. Net::Async::Authentik::API uses these so the distinction is made in one place.

diff_class

The class the ensure_* methods compare with, WWW::Authentik::Diff. Net::Async::Authentik uses the same one.

api_url

print $api->api_url;   # https://id.example.org/api/v3

call

my $result = $api->call( GET => '/core/users/?username=alice' );
my $result = $api->call( POST => '/core/groups/', { name => 'staff' } );

One request against the API. Returns what "send_request" in WWW::Authentik::Role::HTTP returns. The way to reach an endpoint this class has no method for. Mind the trailing slash: authentik answers a path without one with 404, and this method adds nothing.

version

print $api->version->{version_current};   # 2026.8.3

config

The instance's public configuration.

settings

The instance settings, among them default_token_duration, which decides how long a new token lives.

me

Who the API token belongs to.

list_users

my $users = $api->list_users( type => 'internal' );

find_user

my $user = $api->find_user('alice') or die 'no such user';

By username, exactly and case-sensitively: authentik lets Alice and alice exist side by side.

get_user

create_user

my $user = $api->create_user( { username => 'alice', name => 'Alice' } );

update_user

delete_user

set_password

$api->set_password( $user->{pk}, $password );

authentik accepts any password here; a password policy only applies inside a flow.

create_service_account

my $account = $api->create_service_account( name => 'provisioner' );
print $account->{token};   # handed out once, and only here

Creates a user of type service_account together with an app-password token. That token is the password for "client_credentials_token" in WWW::Authentik::OIDC with a username.

list_authenticators

my $devices = $api->list_authenticators( $user->{pk} );

The authenticator devices of a user, as a plain list. Creating a TOTP device over the API is not possible: POST /authenticators/admin/totp/ answers 500 in authentik 2026.8.3. Enrolment goes through the TOTP setup flow.

list_groups

find_group

my $group = $api->find_group('staff');

get_group

create_group

update_group

delete_group

add_user_to_group

$api->add_user_to_group( $group->{pk}, $user->{pk} );

remove_user_from_group

Removing someone who is not a member answers 204 as well.

list_tokens

find_token

my $token = $api->find_token('provisioner');

By identifier, which is also the address of the detail endpoint.

get_token

create_token

my $token = $api->create_token( { identifier => 'provisioner', intent => 'api', expiring => \0 } );

expires cannot be chosen: authentik ignores it and sets the expiry from the instance setting default_token_duration. expiring => \0 is how a token is made to live for ever.

update_token

delete_token

view_token_key

my $key = $api->view_token_key('provisioner')->{key};

set_token_key

$api->set_token_key( 'provisioner', $key );

Puts a key of your choosing on a token, so a setup can know it without reading it back.

list_applications

find_application

my $app = $api->find_application('my-app');

By slug, which is the address of the detail endpoint. Returns nothing when there is none.

create_application

my $app = $api->create_application( { name => 'My App', slug => 'my-app', provider => $provider->{pk} } );

An application holds at most one provider, and a provider belongs to at most one application.

update_application

delete_application

check_access

my $access = $api->check_access('my-app');
my $access = $api->check_access( 'my-app', for_user => $user->{pk} );

Whether the application's policies let someone in: { passing => 1, messages => [], log_messages => [] }.

Without for_user the answer is about the user the API token belongs to. A for_user that no user has is a plain field error (400 {"for_user": "User not found"}), so a stale id cannot pass for an answer — with one exception worth knowing: for_user => 1 is accepted on 2026.8.3 because primary key 1 is authentik's internal AnonymousUser, which "get_user" and "list_users" both deny the existence of.

list_oauth2_providers

find_oauth2_provider

my $provider = $api->find_oauth2_provider('my-app');

By name, which is unique.

get_oauth2_provider

create_oauth2_provider

my $provider = $api->create_oauth2_provider( {
  name               => 'my-app',
  authorization_flow => $flow->{pk},
  invalidation_flow  => $other->{pk},
  redirect_uris      => [ { matching_mode => 'strict', url => 'https://app.example.org/cb' } ],
  grant_types        => [qw( authorization_code refresh_token )],
} );

name, authorization_flow, invalidation_flow and redirect_uris are required. Without grant_types authentik stores an empty list, and such a provider answers every token request with invalid_grant; "ensure_oauth2_provider" refuses to create one.

update_oauth2_provider

delete_oauth2_provider

provider_setup_urls

my $urls = $api->provider_setup_urls( $provider->{pk} );

The OpenID endpoints of the provider. issuer, provider_info, jwks and logout are undef until the provider belongs to an application.

preview_user

my $preview = $api->preview_user( $provider->{pk}, $user->{pk} );

What a token for this user would contain, without issuing one.

list_scope_mappings

find_scope_mapping

my $mapping = $api->find_scope_mapping("authentik default OAuth Mapping: OpenID 'email'");

By name, which is unique. scope_name is not: several mappings may offer the same scope.

find_scope_mappings_by_scope

my $mappings = $api->find_scope_mappings_by_scope(qw( openid email profile ));

One mapping per scope name, in the order the names were given. A scope without a mapping is a validation error naming it. When several mappings offer the same scope the first authentik lists wins.

get_scope_mapping

create_scope_mapping

my $mapping = $api->create_scope_mapping( { name => 'amr', scope_name => 'amr',
  expression => 'return {"amr": request.context.get("amr", [])}' } );

name, scope_name and expression are required, and the expression is compiled when it arrives: a syntax error comes back as a field error on expression.

update_scope_mapping

delete_scope_mapping

test_property_mapping

my $result = $api->test_property_mapping( $mapping->{pk}, user => $user->{pk} );

list_flows

find_flow

my $flow = $api->find_flow('default-authentication-flow');

By slug. The pk in the answer is the UUID everything else refers to.

create_flow

my $flow = $api->create_flow( { name => 'Probe', slug => 'probe', title => 'Probe',
  designation => 'authentication' } );

update_flow

delete_flow

export_flow

my $yaml = $api->export_flow('default-authentication-flow');

The flow as a blueprint. authentik answers YAML with a text/html content type, so this returns the body as a string, not a structure.

stage_types

All 25 stage types authentik offers, each with its component and model_name.

list_stages

my $stages = $api->list_stages;

Every stage of every type, in the reduced representation /stages/all/ gives out: pk, name, component and meta_model_name, without the fields of its type. Use "get_stage" with the type for the whole thing.

find_stage

my $stage = $api->find_stage('default-authentication-mfa-validation');

By name, which is unique across all stage types.

get_stage

my $stage = $api->get_stage( 'authenticator/validate', $uuid );

$type is the path under /stages/: password, identification, user_login, authenticator/validate, authenticator/totp, consent and so on. all is refused, because that endpoint can only be read.

create_stage

my $stage = $api->create_stage( password => { name => 'probe-password',
  backends => ['authentik.core.auth.InbuiltBackend'] } );

update_stage

delete_stage

Deleting a stage deletes the bindings that point at it.

list_bindings

my $bindings = $api->list_bindings( flow => 'my-flow' );
my $bindings = $api->list_bindings( target => $flow->{pk} );

flow takes a slug or a UUID and is turned into target. target itself goes through as it is, and authentik answers a slug there with a field error.

get_binding

create_binding

my $binding = $api->create_binding( { target => $flow->{pk}, stage => $stage->{pk}, order => 20 } );

target, stage and order together are unique.

update_binding

delete_binding

list_brands

current_brand

my $brand = $api->current_brand;

The brand that applies to this request, as flow_device_code and the other flows it names — what a device flow needs for a person to be able to approve it.

This is the public view, and it cannot be written back. It has neither brand_uuid nor domain, so there is nothing to hand "update_brand". To change a brand, take it from "list_brands":

my ( $brand ) = grep { $_->{default} } @{ $api->list_brands };
$api->update_brand( $brand->{brand_uuid}, { flow_device_code => $flow->{pk} } );

update_brand

$api->update_brand( $brand->{brand_uuid}, { flow_device_code => $flow->{pk} } );

A brand is addressed by brand_uuid, not by pk.

list_certificates

find_certificate

my $cert = $api->find_certificate('authentik Self-signed Certificate');

list_blueprints

get_blueprint

create_blueprint

my $blueprint = $api->create_blueprint( { name => 'probe', content => $yaml } );

The YAML is validated when it arrives; an unknown model is a field error on content.

apply_blueprint

$api->apply_blueprint( $blueprint->{pk} );

Asks authentik to apply it. The worker does that in the background, so the answer still says status => 'unknown'; read the blueprint again a moment later to see successful.

delete_blueprint

resolvable_fields

The table "resolve" works from: field name to the identifier form, the key that forces a lookup, and the method that does it.

resolve

my $rep = $api->resolve( { authorization_flow => 'default-authentication-flow' } );
# { authorization_flow => '57b03d1d-...' }

authentik wants identifiers where a person knows a name. This turns the names into identifiers, and it never guesses: for every field, one shape counts as the identifier and everything else is a name.

field                 identifier is          forced lookup
--------------------- ---------------------- ---------------------------
provider              an integer             provider_name
user                  an integer             user_name
authorization_flow    a UUID                 authorization_flow_slug
invalidation_flow     a UUID                 invalidation_flow_slug
authentication_flow   a UUID                 authentication_flow_slug
configure_flow        a UUID                 configure_flow_slug
flow_device_code      a UUID                 flow_device_code_slug
signing_key           a UUID                 signing_key_name
encryption_key        a UUID                 encryption_key_name
groups                UUIDs                  group_names
property_mappings     UUIDs                  property_mapping_names
configuration_stages  UUIDs                  configuration_stage_names

So a provider named 123 cannot be reached through provider, because provider => '123' is the provider with the primary key 123. Write provider_name => '123'; the forced form never looks at the shape of the value. The same goes for a flow whose slug happens to look like a UUID. Giving both forms of one field is a validation error, and so is a name nothing matches.

scopes is the one field with no counterpart in authentik: a list of scope names that becomes property_mappings. Giving scopes together with property_mappings is a validation error.

ensure_user

my $r = $api->ensure_user( username => 'alice', name => 'Alice',
  email => 'alice@example.org', password => $pw, group_names => ['staff'] );

Found by username. password is not a field of the user: it is set once, right after the user is created, and never touched again, so a setup that runs twice does not reset it. Call "set_password" to change one.

ensure_group

Found by name.

ensure_token

my $r = $api->ensure_token( identifier => 'provisioner', intent => 'api',
  expiring => \0, user_name => 'akadmin', key => $key );

Found by identifier. key is like password: put on the token when it is created, never after. expires is refused, because authentik ignores it.

ensure_application

my $r = $api->ensure_application( slug => 'my-app', name => 'My App', provider_name => 'my-app' );

Found by slug.

ensure_oauth2_provider

my $r = $api->ensure_oauth2_provider(
  name                    => 'my-app',
  authorization_flow_slug => 'default-provider-authorization-implicit-consent',
  invalidation_flow_slug  => 'default-provider-invalidation-flow',
  client_type             => 'confidential',
  grant_types             => [qw( authorization_code refresh_token )],
  redirect_uris           => [ { matching_mode => 'strict', url => 'https://app.example.org/cb' } ],
  scopes                  => [qw( openid email profile )],
  signing_key_name        => 'authentik Self-signed Certificate',
);

Found by name. Creating one without a non-empty grant_types is refused: authentik would store an empty list and the provider would answer every token request with invalid_grant. Updating an existing provider does not need them.

redirect_uris may be written without redirect_uri_type; authentik adds it, and the comparison knows that. property_mappings come back in authentik's own order, and the comparison treats them as a set, so a second run reports no change.

ensure_scope_mapping

Found by name.

ensure_flow

Found by slug.

ensure_stage

my $r = $api->ensure_stage( 'authenticator/validate',
  name                  => 'default-authentication-mfa-validation',
  not_configured_action => 'deny',
);

Found by name, which is unique across all stage types; read, created and written through the typed endpoint. Note that not_configured_action => 'configure' needs configuration_stages in the same write, which happens by itself here because every differing key goes out in one PATCH.

ensure_binding

my $r = $api->ensure_binding( flow => 'probe-flow', stage => 'probe-password', order => 20 );

flow takes a slug or a UUID, stage a name or a UUID. A flow binds a given stage once: a second call with another order moves the binding instead of adding one. Bind a stage twice with "create_binding".

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-www-authentik/issues.

IRC

Join #kubernetes on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.