NAME
Airlock - Embeddable device authorization (RFC 8628) with step-up second factors
VERSION
version 0.001
SYNOPSIS
my $airlock = Airlock->new(
clients => { 'my-cli' => { name => 'My CLI', scopes => [qw( read admin )] } },
verification_uri => 'https://my.example.org/airlock',
store => { insert => sub {...}, find => sub {...}, update => sub {...}, purge => sub {...} },
policy => { step_up => { admin => ['totp'] } },
factors => [ Airlock::Factor::Callback->new( name => 'totp', amr => 'otp', verify => sub {...} ) ],
);
# machine side: mount the two JSON endpoints
my $app = $airlock->to_app;
# human side: the host application renders its own page
my $view = $airlock->inspect( $typed_code, subject => $subject ) or return not_found();
my $needs = $airlock->requirements( $view, $subject ); # ['totp']
my $done = $airlock->approve( $typed_code, subject => $subject, proofs => { totp => $typed_totp } );
DESCRIPTION
Airlock approves a waiting request from an already trusted session: the server side of the OAuth 2.0 Device Authorization Grant (RFC 8628), with an optional second factor before the approval counts.
It is a core to embed. The host application supplies who is logged in, the approval page and where rows are kept. Airlock supplies codes, the state machine, poll rules, one-time redemption, step-up policy, second factors and the two machine endpoints.
A request moves pending to approved, denied or expired, and approved to redeemed exactly once.
clients
Required. Who may ask. A hash of client id to { name => ..., scopes => [...] }, or a coderef called with a client id that returns such a hash or nothing. A client without scopes may ask for any scope.
verification_uri
Required. Where the host application serves its approval page.
store
Hash of four coderefs: insert, find, update and optionally purge. In the changes given to update, a reference to a number means "add this to the column", which the store has to do atomically. The contract is documented in Airlock::Store::Memory and checked by Airlock::Test::Store. Default: an in-process store.
policy
An Airlock::Policy or the hash to build one from. Default: no factors.
factors
The Airlock::Factor objects a policy may name.
issuer
Optional. Coderef called with the grant, returning the token response as a hash. Without it Airlock issues an opaque random token, keeps its hash in the store and checks it with "verify_token".
on_event
Optional. Coderef called with a hash for opened, approved, denied, redeemed, factor_failed and code_miss. Hang the audit log and rate limits here. Events never carry codes, tokens or proofs.
now
Coderef returning the current epoch. For tests.
expires_in
Seconds a request lives. Default 600.
interval
Seconds a client has to wait between polls. Default 5.
token_ttl
Seconds an opaque token lives. Default 3600.
max_factor_failures
Wrong proofs after which a request is denied. Default 5.
code
The Airlock::Code that generates and normalizes codes.
row_fields
my @columns = Airlock->row_fields;
Every key of a row the store sees. All values are plain scalars or undef.
client
my $client = $airlock->client('my-cli'); # { id => ..., name => ..., scopes => [...] }
The registered client, or nothing.
factor
my $upstream = $airlock->factor('upstream');
The factor of that name. Croaks when a policy names a factor that was never configured.
open
my $result = $airlock->open( client_id => 'my-cli', scope => 'read admin', origin => { ip => $ip, ua => $ua } );
Starts a request. On success data is the device authorization response of RFC 8628 section 3.2. Fails with invalid_client or invalid_scope.
inspect
my $view = $airlock->inspect( $typed_code, subject => $subject ) or return not_found();
What an approval page has to show: user_code, client_id, client_name, scopes, origin (ip, ua), created, age and expires_in. Returns nothing when the code is unknown, used up or expired. Looking never approves anything.
requirements
my $names = $airlock->requirements( $view, $subject ); # ['totp']
The factor names this approval needs, so the page can ask for them.
approve
my $result = $airlock->approve( $typed_code, subject => $subject, proofs => { totp => '123456' } );
Approves a pending request on behalf of the subject, a hash with at least id and optionally amr, acr and auth_time. An empty proof counts as no proof. Approving a second time with the same subject, as a double click does, succeeds again. Fails with unknown_code, reauth_required, factor_unavailable, factor_required (missing names what to ask for), factor_failed or too_many_failures. Croaks without a subject id.
deny
my $result = $airlock->deny( $typed_code, subject => $subject );
Refuses a pending request. The client's next poll gets access_denied.
redeem
my $result = $airlock->redeem( device_code => $device_code, client_id => 'my-cli' );
One poll of the client. Succeeds exactly once per approved request, with the token response in data. Otherwise fails with authorization_pending, slow_down, access_denied, expired_token, invalid_grant or invalid_request. An exception from the issuer propagates; the request is used up by then.
verify_token
my $grant = $airlock->verify_token($bearer) or return unauthorized();
For opaque tokens: the grant behind a token that is known, active and not expired, or nothing.
revoke_token
$airlock->revoke_token($bearer);
Ends an opaque token. Returns 1 when there was an active one.
purge
my $removed = $airlock->purge;
Removes expired requests and tokens through the store's purge sub. Call it from a timer or a cron job.
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.