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.