Airlock

 view release on metacpan or  search on metacpan

lib/Airlock/Upstream/Authentik.pm  view on Meta::CPAN


version 0.001

=head1 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) );

=head1 DESCRIPTION

When the host application logs people in through authentik, this class turns
the token claims into the subject L<Airlock> wants, and builds the
L<Airlock::Factor::Upstream> that recognises an authentik login with a second
factor.

Unlike L<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 F<t/91-live-authentik.t>, with the default flows and no
mapper added:

=over 4

=item *

A password login carries C<< amr => ['pwd'] >>, a login with TOTP
C<< amr => [ 'pwd', 'mfa' ] >>. The default L</mfa_amr> recognises C<mfa>, so
the factor tells the two apart out of the box.

=item *

C<acr> is C<goauthentik.io/providers/oauth2/default> for both, and is of no
use here. L</mfa_acr> is therefore empty, and C<acr_values> in an
authorization request does not change it either.

=item *

C<auth_time> is the moment the B<session> began, not the moment the token was
minted, and it survives both a refresh and further authorization requests.
That is what L<Airlock::Factor::Upstream/max_age> 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 by
C<< max_age => 300 >>.

=item *

C<amr> and C<auth_time> are 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 C<< { active: false } >> and nothing else.)

=back

Two qualifications on C<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 C<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 B<fails open>, and
C<max_age> cannot catch it.

=head2 Asking for a fresh authentication

L<Airlock::Factor::Upstream/reauth_params> returns C<< max_age => 0 >>, which
is what OpenID Connect says for "authenticate this person again". B<authentik
2026.8.3 throws that one value away>: its authorization endpoint only looks at
C<max_age> when it is true, and zero is not, so a code comes back at once with
the C<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. L</reauth_params>
gives the parameters that do work on authentik; use them in place of the
factor's.

=head2 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. C<prompt=login> is plain OpenID Connect, authentik honours it, and
unlike C<max_age> it does not depend on how old the session happens to be.

With C<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 C<max_age> of 0 is refused rather than sent, because authentik
would ignore it.

=head2 Requiring the second factor in authentik

Nothing here forces anyone to use TOTP; it reports what happened. To make
authentik insist, set C<not_configured_action> on the Authenticator Validation
stage of the authentication flow from C<skip> to C<deny> (a person without a
configured authenticator is refused) or to C<configure> (they are sent through
the setup stage first, which also needs C<configuration_stages>).
F<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.

=head2 mfa_amr

C<amr> values that mean a second factor was used. authentik writes C<mfa>,
which the default covers.

=head2 mfa_acr

C<acr> values that mean a second factor was used. Empty, and worth leaving
empty: authentik sends one constant C<acr> whatever happened.



( run in 0.837 second using v1.01-cache-2.11-cpan-3f962061493 )