NAME
Airlock::Upstream::Authentik - Use an authentik login as the subject of an Airlock approval
VERSION
version 0.001
SYNOPSIS
my $authentik = Airlock::Upstream::Authentik->new;
my $airlock = Airlock->new(
policy => { always => ['upstream'] },
factors => [ $authentik->factor( max_age => 300 ) ],
...
);
# in the approval action, with the claims of the person's ID token
my $result = $airlock->approve( $code, subject => $authentik->subject($claims) );
DESCRIPTION
When the host application logs people in through authentik, this class turns the token claims into the subject Airlock wants, and builds the Airlock::Factor::Upstream that recognises an authentik login with a second factor.
Unlike Airlock::Upstream::Keycloak, there is nothing to configure in authentik first. These are the claims of authentik 2026.8.3, observed through the whole chain in t/91-live-authentik.t, with the default flows and no mapper added:
A password login carries
amr => ['pwd'], a login with TOTPamr => [ 'pwd', 'mfa' ]. The default "mfa_amr" recognisesmfa, so the factor tells the two apart out of the box.acrisgoauthentik.io/providers/oauth2/defaultfor both, and is of no use here. "mfa_acr" is therefore empty, andacr_valuesin an authorization request does not change it either.auth_timeis the moment the session began, not the moment the token was minted, and it survives both a refresh and further authorization requests. That is what "max_age" in Airlock::Factor::Upstream wants: it measures how old the authentication is, not how fresh the token is. A token minted now from a session that is ten minutes old is correctly refused bymax_age => 300.amrandauth_timeare the same in the ID token and the access token. (Introspection would say the same, but it needs a confidential client; the public one the test uses gets{ active: false }and nothing else.)
Two qualifications on auth_time, both read in authentik's source rather than provoked here. A client-credentials or token-exchange grant sets it to the minting time, not to a login — harmless for this factor, which refuses anything without an amr, but it is not the session's time there. And when authentik finds no login event for a session, it falls back to the current time, so an old authentication can look new: that direction fails open, and max_age cannot catch it.
Asking for a fresh authentication
"reauth_params" in Airlock::Factor::Upstream returns max_age => 0, which is what OpenID Connect says for "authenticate this person again". authentik 2026.8.3 throws that one value away: its authorization endpoint only looks at max_age when it is true, and zero is not, so a code comes back at once with the auth_time of the old session.
Every other value works. Measured against a session six seconds old:
no parameter a code, no new login
max_age=0 a code, no new login <- what reauth_params sends
max_age=1 sent back to log in
max_age=2 sent back to log in
max_age=3600 a code, no new login (the session is younger)
prompt=login sent back to log in
So the escape hatch is not broken here, it is one value off. "reauth_params" gives the parameters that do work on authentik; use them in place of the factor's.
reauth_params
my $params = $authentik->reauth_params; # { prompt => 'login' }
my $params = $authentik->reauth_params( max_age => 300 );
What to add to the authorization request to send someone back for a fresh login. prompt=login is plain OpenID Connect, authentik honours it, and unlike max_age it does not depend on how old the session happens to be.
With max_age it asks for an authentication no older than that many seconds instead — the same number you gave the factor, so the two agree on what counts as too old. A max_age of 0 is refused rather than sent, because authentik would ignore it.
Requiring the second factor in authentik
Nothing here forces anyone to use TOTP; it reports what happened. To make authentik insist, set not_configured_action on the Authenticator Validation stage of the authentication flow from skip to deny (a person without a configured authenticator is refused) or to configure (they are sent through the setup stage first, which also needs configuration_stages). t/authentik/setup.pl does neither; it builds the test fixtures and leaves the stage alone, because the live test wants both kinds of login.
mfa_amr
amr values that mean a second factor was used. authentik writes mfa, which the default covers.
mfa_acr
acr values that mean a second factor was used. Empty, and worth leaving empty: authentik sends one constant acr whatever happened.
subject
my $subject = $authentik->subject($claims);
The Airlock subject for a set of token claims: id from sub, and amr, acr and auth_time as authentik sent them. Croaks without sub.
sub is the provider's sub_mode, by default a hash of the user's id, so it differs between two applications of one authentik. That is fine for Airlock, which only compares it with itself, but it is not a user id to store.
factor
my $factor = $authentik->factor( max_age => 300 );
An Airlock::Factor::Upstream that holds for an authentik login with a second factor. Takes its options.
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-airlock/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.