[libnet-duo-perl] 06/25: Imported Upstream version 1.00~alpha2

Russ Allbery eagle at eyrie.org
Mon Jan 8 02:55:58 UTC 2018


This is an automated email from the git hooks/post-receive script.

rra pushed a commit to branch master
in repository libnet-duo-perl.

commit fbd6d7f69c778f99aab966b3c7734d7d5e8859ad
Author: Russ Allbery <rra at cpan.org>
Date:   Wed Jul 9 01:19:01 2014

    Imported Upstream version 1.00~alpha2---
 MANIFEST                            |  11 +-
 META.json                           |   8 +
 META.yml                            |   6 +
 TODO                                |  10 -
 lib/Net/Duo/Admin.pm                | 228 ++++++++++++++++++-
 lib/Net/Duo/Admin/Integration.pm    | 440 ++++++++++++++++++++++++++++++++++++
 lib/Net/Duo/Admin/Phone.pm          |   2 +-
 lib/Net/Duo/Admin/Token.pm          |  25 ++
 lib/Net/Duo/Auth.pm                 | 378 ++++++++++++++++++++++++-------
 lib/Net/Duo/Auth/Async.pm           | 188 +++++++++++++++
 lib/Net/Duo/Object.pm               |  14 +-
 t/admin/groups.t                    |   0
 t/admin/integrations.t              | 136 +++++++++++
 t/admin/misc.t                      | 131 +++++++++++
 t/admin/phones.t                    |   2 +-
 t/admin/tokens.t                    |   0
 t/auth/auth.t                       | 169 ++++++++++++++
 t/auth/basic.t                      |  27 +--
 t/auth/sms.t                        |  53 +++--
 t/auth/validation.t                 | 126 -----------
 t/data/responses/integration.json   |  11 +
 t/data/responses/integrations.json  |  21 ++
 t/data/responses/log-admin.json     |   9 +
 t/data/responses/log-auth.json      |  10 +
 t/data/responses/log-telephony.json |   9 +
 t/lib/Test/RRA/Duo.pm               |  64 ++++--
 26 files changed, 1792 insertions(+), 286 deletions(-)

diff --git a/MANIFEST b/MANIFEST
index a49bb1b..e14f7da 100644
--- a/MANIFEST
+++ b/MANIFEST
@@ -4,10 +4,12 @@ Changes
 lib/Net/Duo.pm
 lib/Net/Duo/Admin.pm
 lib/Net/Duo/Admin/Group.pm
+lib/Net/Duo/Admin/Integration.pm
 lib/Net/Duo/Admin/Phone.pm
 lib/Net/Duo/Admin/Token.pm
 lib/Net/Duo/Admin/User.pm
 lib/Net/Duo/Auth.pm
+lib/Net/Duo/Auth/Async.pm
 lib/Net/Duo/Exception.pm
 lib/Net/Duo/Object.pm
 LICENSE
@@ -15,18 +17,25 @@ MANIFEST			This list of files
 MANIFEST.SKIP
 README
 t/admin/groups.t
+t/admin/integrations.t
+t/admin/misc.t
 t/admin/phones.t
 t/admin/tokens.t
 t/admin/users.t
+t/auth/auth.t
 t/auth/basic.t
 t/auth/sms.t
-t/auth/validation.t
 t/data/integrations/admin.json
 t/data/integrations/auth.json
 t/data/perl.conf
 t/data/perlcriticrc
 t/data/perltidyrc
 t/data/responses/group-create.json
+t/data/responses/integration.json
+t/data/responses/integrations.json
+t/data/responses/log-admin.json
+t/data/responses/log-auth.json
+t/data/responses/log-telephony.json
 t/data/responses/phone-create.json
 t/data/responses/token-create.json
 t/data/responses/user-create.json
diff --git a/META.json b/META.json
index c80aca0..52a6b23 100644
--- a/META.json
+++ b/META.json
@@ -43,6 +43,10 @@
          "file" : "lib/Net/Duo/Admin/Group.pm",
          "version" : "1.00"
       },
+      "Net::Duo::Admin::Integration" : {
+         "file" : "lib/Net/Duo/Admin/Integration.pm",
+         "version" : "1.00"
+      },
       "Net::Duo::Admin::Phone" : {
          "file" : "lib/Net/Duo/Admin/Phone.pm",
          "version" : "1.00"
@@ -59,6 +63,10 @@
          "file" : "lib/Net/Duo/Auth.pm",
          "version" : "1.00"
       },
+      "Net::Duo::Auth::Async" : {
+         "file" : "lib/Net/Duo/Auth/Async.pm",
+         "version" : "1.00"
+      },
       "Net::Duo::Exception" : {
          "file" : "lib/Net/Duo/Exception.pm",
          "version" : "1.00"
diff --git a/META.yml b/META.yml
index 9ded68e..7e3b6f8 100644
--- a/META.yml
+++ b/META.yml
@@ -22,6 +22,9 @@ provides:
   Net::Duo::Admin::Group:
     file: lib/Net/Duo/Admin/Group.pm
     version: '1.00'
+  Net::Duo::Admin::Integration:
+    file: lib/Net/Duo/Admin/Integration.pm
+    version: '1.00'
   Net::Duo::Admin::Phone:
     file: lib/Net/Duo/Admin/Phone.pm
     version: '1.00'
@@ -34,6 +37,9 @@ provides:
   Net::Duo::Auth:
     file: lib/Net/Duo/Auth.pm
     version: '1.00'
+  Net::Duo::Auth::Async:
+    file: lib/Net/Duo/Auth/Async.pm
+    version: '1.00'
   Net::Duo::Exception:
     file: lib/Net/Duo/Exception.pm
     version: '1.00'
diff --git a/TODO b/TODO
index 734b9ac..01cae6e 100644
--- a/TODO
+++ b/TODO
@@ -1,15 +1,5 @@
                            Net::Duo To-Do List
 
-Authentication:
-
- * The sms_passcodes method should return true or undef instead of a
-   status and should throw an exception using status_msg on failure, and
-   similarly for validate_passcode and auth_status.
-
- * Consider having validate_out_of_band return an object for an async
-   authentication that supports a status method and an id accessor method,
-   and can also be constructed from its id.
-
 Test suite:
 
  * Test::RRA::Duo should dynamically discover the fields that can compared
diff --git a/lib/Net/Duo/Admin.pm b/lib/Net/Duo/Admin.pm
index 6060d3d..3e01760 100644
--- a/lib/Net/Duo/Admin.pm
+++ b/lib/Net/Duo/Admin.pm
@@ -15,12 +15,114 @@ use warnings;
 
 use parent qw(Net::Duo);
 
+use Net::Duo::Admin::Integration;
 use Net::Duo::Admin::User;
 
 ##############################################################################
 # Admin API methods
 ##############################################################################
 
+# Retrieve all integrations associated with a Duo account.
+#
+# $self - The Net::Duo::Admin object
+#
+# Returns: List of Net::Duo::Admin::Integration objects
+#  Throws: Net::Duo::Exception on failure
+sub integrations {
+    my ($self) = @_;
+
+    # Make the Duo call and get the decoded result.
+    my $result = $self->call_json('GET', '/admin/v1/integrations');
+
+    # Convert the returned integrations into Net::Duo::Admin::Integration
+    # objects.
+    my @integrations;
+    for my $integration (@{$result}) {
+        my $object = Net::Duo::Admin::Integration->new($self, $integration);
+        push(@integrations, $object);
+    }
+    return @integrations;
+}
+
+# Retrieve logs of administrative actions.  At most 1000 entries are returned,
+# so the caller may need to call this repeatedly with different mintime
+# parameters to retrieve all of the logs.
+#
+# $self    - The Net::Duo::Admin object
+# $mintime - Only return records with a timestamp after this (optional)
+#
+# Returns: A list of log entries, each of which is a hash reference with keys:
+#            timestamp   - Time of event in seconds since epoch
+#            username    - Admin username or API for changes via API
+#            action      - The action taken
+#            object      - The object on which the action was performed
+#            description - The details of what was changed
+#  Throws: Net::Duo::Exception on failure
+sub logs_administrator {
+    my ($self, $mintime) = @_;
+
+    # Make the Duo call and get the decoded result.
+    my $args   = defined($mintime) ? { mintime => $mintime } : {};
+    my $uri    = '/admin/v1/logs/administrator';
+    my $result = $self->call_json('GET', $uri, $args);
+
+    # Return the result as a list.
+    return @{$result};
+}
+
+# Retrieve authentication logs.  At most 1000 entries are returned, so the
+# caller may need to call this repeatedly with different mintime parameters to
+# retrieve all of the logs.
+#
+# $self    - The Net::Duo::Admin object
+# $mintime - Only return records with a timestamp after this (optional)
+#
+# Returns: A list of log entries, each of which is a hash reference with keys:
+#            timestamp   - Time of event in seconds since epoch
+#            username    - The authenticating username
+#            factor      - The authentication factor
+#            result      - The result of the authentication
+#            ip          - IP address from which the request originated
+#            integration - Integration name attempting the authentication
+#  Throws: Net::Duo::Exception on failure
+sub logs_authentication {
+    my ($self, $mintime) = @_;
+
+    # Make the Duo call and get the decoded result.
+    my $args   = defined($mintime) ? { mintime => $mintime } : {};
+    my $uri    = '/admin/v1/logs/authentication';
+    my $result = $self->call_json('GET', $uri, $args);
+
+    # Return the result as a list.
+    return @{$result};
+}
+
+# Retrieve telephony logs.  At most 1000 entries are returned, so the caller
+# may need to call this repeatedly with different mintime parameters to
+# retrieve all of the logs.
+#
+# $self    - The Net::Duo::Admin object
+# $mintime - Only return records with a timestamp after this (optional)
+#
+# Returns: A list of log entries, each of which is a hash reference with keys:
+#            timestamp - Time of event in seconds since epoch
+#            context   - How this telephony event was initiated
+#            type      - The event type
+#            phone     - The phone number for this event
+#            credits   - How many telephony credits this event cost
+#  Throws: Net::Duo::Exception on failure
+sub logs_telephony {
+    my ($self, $mintime) = @_;
+
+    # Make the Duo call and get the decoded result.
+    my $args   = defined($mintime) ? { mintime => $mintime } : {};
+    my $uri    = '/admin/v1/logs/telephony';
+    my $result = $self->call_json('GET', $uri, $args);
+
+    # Return the result as a list.
+    return @{$result};
+}
+
 # Retrieve a single user by username.  An empty reply indicates that no user
 # by that username exists.
 #
@@ -72,7 +174,8 @@ sub users {
 __END__
 
 =for stopwords
-Allbery Auth MERCHANTABILITY NONINFRINGEMENT sublicense
+Allbery Auth MERCHANTABILITY NONINFRINGEMENT sublicense MINTIME
+integrations ip
 
 =head1 NAME
 
@@ -134,6 +237,129 @@ documentation of the possible arguments.
 
 =over 4
 
+=item integrations()
+
+Retrieves all the integrations currently present in this Duo account and
+returns them as a list of Net::Duo::Admin::Integration objects.  Be aware
+that this list may be quite long if the Duo account supports many
+integrations, and the entire list is read into memory.
+
+=item logs_administrator([MINTIME])
+
+Returns a list of administrative actions.  Each member of this list will
+be a reference to a hash with the following keys:
+
+=over 4
+
+=item timestamp
+
+The time of the event in seconds since UNIX epoch.
+
+=item username
+
+The username of the administrator, or C<API> if the action was performed
+via the Admin API.
+
+=item action
+
+The administrator action.  See the Duo Admin API documentation for a full
+list of valid values.
+
+=item object
+
+An identifier for the object that was acted on.  What fields are used as
+an identifier will vary by type of object.
+
+=item description
+
+The details of what was changed.
+
+=back
+
+At most 1,000 log entries will be returned.  If MINTIME is provided, only
+records with a time stamp after MINTIME will be returned.  All records can
+therefore be retrieved by calling this method repeatedly, first with no
+MINTIME and then with MINTIME matching the timestamp of the last returned
+record from the previous call.
+
+=item logs_authentication([MINTIME])
+
+Returns a list of authentication attempts.  Each member of the list will
+be a reference to a hash with the following keys:
+
+=over 4
+
+=item timestamp
+
+The time of the event in seconds since UNIX epoch.
+
+=item username
+
+The authenticating user's username.
+
+=item factor
+
+The authentication factor, chosen from C<phone call>, C<passcode>,
+C<bypass code>, C<sms passcode>, C<sms refresh>, or C<duo push>.
+
+=item result
+
+The result of the authentication, chosen from C<success>, C<failure>,
+C<error>, or C<fraud>.
+
+=item ip
+
+The IP address from which the authentication attempt originated.
+
+=item integration
+
+The name of the integration from which the authentication attempt
+originated.
+
+=back
+
+At most 1,000 authentication log entries will be returned.  If MINTIME is
+provided, only records with a time stamp after MINTIME will be returned.
+All records can therefore be retrieved by calling this method repeatedly,
+first with no MINTIME and then with MINTIME matching the timestamp of the
+last returned record from the previous call.
+
+=item logs_telephony([MINTIME])
+
+Returns a list of telephony events.  Each member of this list will be a
+reference to a hash with the following keys:
+
+=over 4
+
+=item timestamp
+
+The time of the event in seconds since UNIX epoch.
+
+=item context
+
+How this telephony event was initiated.  This will be one of
+C<administrator login>, C<authentication>, C<enrollment>, or C<verify>.
+
+=item type
+
+The event type.  One of C<sms> or C<phone>.
+
+=item phone
+
+The phone number that initiated this event.
+
+=item credits
+
+How many telephony credits this event cost.
+
+=back
+
+At most 1,000 log entries will be returned.  If MINTIME is provided, only
+records with a time stamp after MINTIME will be returned.  All records can
+therefore be retrieved by calling this method repeatedly, first with no
+MINTIME and then with MINTIME matching the timestamp of the last returned
+record from the previous call.
+
 =item user(USERNAME)
 
 Retrieves a single user by username and returns it as a
diff --git a/lib/Net/Duo/Admin/Integration.pm b/lib/Net/Duo/Admin/Integration.pm
new file mode 100644
index 0000000..bc22b6a
--- /dev/null
+++ b/lib/Net/Duo/Admin/Integration.pm
@@ -0,0 +1,440 @@
+# Representation of a single Duo integration for the Admin API.
+#
+# This class wraps the Duo representation of a single Duo integration, as
+# returned by (for example) the Admin /integrations REST endpoint.
+
+package Net::Duo::Admin::Integration 1.00;
+
+use 5.014;
+use strict;
+use warnings;
+
+use parent qw(Net::Duo::Object);
+
+# Data specification for converting JSON into our object representation.  See
+# the Net::Duo::Object documentation for syntax information.
+## no critic (Subroutines::ProhibitUnusedPrivateSubroutines)
+sub _fields {
+    return {
+        adminapi_admins               => ['simple', 'zero_or_one'],
+        adminapi_info                 => ['simple', 'zero_or_one'],
+        adminapi_integrations         => ['simple', 'zero_or_one'],
+        adminapi_read_log             => ['simple', 'zero_or_one'],
+        adminapi_read_resource        => ['simple', 'zero_or_one'],
+        adminapi_settings             => ['simple', 'zero_or_one'],
+        adminapi_write_resource       => ['simple', 'zero_or_one'],
+        enroll_policy                 => 'simple',
+        greeting                      => 'simple',
+        groups_allowed                => 'array',
+        integration_key               => 'simple',
+        ip_whitelist                  => 'array',
+        ip_whitelist_enroll_policy    => 'simple',
+        name                          => 'simple',
+        notes                         => 'simple',
+        secret_key                    => 'simple',
+        trusted_device_days           => 'simple',
+        type                          => 'simple',
+        username_normalization_policy => 'simple',
+        visual_style                  => 'simple',
+    };
+}
+## use critic
+
+# Install our accessors.
+Net::Duo::Admin::Integration->install_accessors;
+
+# Override the new method to support creating an integration from an ID
+# instead of decoded JSON data.
+#
+# $class      - Class of object to create
+# $duo        - Net::Duo object to use to create the object
+# $id_or_data - Integration ID or reference to data
+#
+# Returns: Newly-created object
+#  Throws: Net::Duo::Exception on any problem creating the object
+sub new {
+    my ($class, $duo, $id_or_data) = @_;
+    if (!ref($id_or_data)) {
+        my $uri = "/admin/v1/integrations/$id_or_data";
+        $id_or_data = $duo->call_json('GET', $uri);
+    }
+    return $class->SUPER::new($duo, $id_or_data);
+}
+
+# Override the create method to add the appropriate URI.
+#
+# $class    - Class of object to create
+# $duo      - Net::Duo object to use to create the object
+# $data_ref - Data for new object as a reference to a hash
+#
+# Returns: Newly-created object
+#  Throws: Net::Duo::Exception on any problem creating the object
+sub create {
+    my ($class, $duo, $data_ref) = @_;
+    return $class->SUPER::create($duo, '/admin/v1/integrations', $data_ref);
+}
+
+# Delete the integration from Duo.  After this call, the object should be
+# treated as read-only since it can no longer be usefully updated.
+#
+# $self - The Net::Duo::Admin::Integration object to delete
+#
+# Returns: undef
+#  Throws: Net::Duo::Exception on any problem deleting the object
+## no critic (Subroutines::ProhibitBuiltinHomonyms)
+sub delete {
+    my ($self) = @_;
+    my $id = $self->{integration_key};
+    $self->{_duo}->call_json('DELETE', "/admin/v1/integrations/$id");
+    return;
+}
+## use critic
+
+1;
+__END__
+
+=for stopwords
+Allbery MERCHANTABILITY NONINFRINGEMENT CIDR CSV integrations sublicense
+
+=head1 NAME
+
+Net::Duo::Admin::Integration - Representation of a Duo integration
+
+=head1 SYNOPSIS
+
+    my $decoded_json = get_json();
+    my $integration = Net::Duo::Admin::Integration->new($decoded_json);
+    say $integration->secret_key;
+
+=head1 DESCRIPTION
+
+An integration is Duo's name for the metadata for a system or service that
+is allowed to use one or more of the Duo APIs.  This object is the Perl
+representation of a Duo integration as returned by the Duo Admin API,
+usually via the integrations() method of Net::Duo::Admin or by retrieving
+an integration by integration key.
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item create(DUO, DATA)
+
+Creates a new integration in Duo and returns the resulting integration as
+a new Net::Duo::Admin::Integration object.  DUO is the Net::Duo object
+that should be used to perform the creation.  DATA is a reference to a
+hash with one or more of the following keys (the C<name> and C<type> keys
+are required):
+
+=over 4
+
+=item adminapi_admins
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API methods.  Optional and defaults to
+false.
+
+=item adminapis_info
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API account info methods.  Optional and
+defaults to false.
+
+=item adminapis_integrations
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API integration methods.  Optional and
+defaults to false.
+
+=item adminapis_read_log
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API log methods.  Optional and defaults
+to false.
+
+=item adminapis_read_resource
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API methods that retrieve objects such
+as users, phones, and hardware tokens.  Setting this key does not grant
+permission to change those objects or create new ones.  Optional and
+defaults to false.
+
+=item adminapis_settings
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API settings methods.  These control
+global settings for the entire Duo account.  Optional and defaults to
+false.
+
+=item adminapis_write_resource
+
+Only valid for integrations of type C<adminapi>.  Set to a true value to
+grant permission to use all Admin API methods that create or modify
+objects such as as users, phones, and hardware tokens.  Optional and
+defaults to false.
+
+=item enroll_policy
+
+What to do after an enrolled user passes primary authentication.  See the
+L</enroll_policy()> method below for the possible values.  Optional and
+defaults to C<enroll>.
+
+=item greeting
+
+Voice greeting read before the authentication instructions to users who
+authenticate with a phone callback.  Optional.
+
+=item groups_allowed
+
+A comma-separated list of group IDs that are allowed to authenticate with
+the integration.  Optional.  By default, all groups are allowed.
+
+=item ip_whitelist
+
+CSV string of trusted IPs or IP ranges.  Both CIDR-style ranges and ranges
+specified by two IP addresses separated by a dash (C<->) are supported.
+Authentications from these IP addresses will not require a second factor.
+
+This can only be set for certain integrations.  For the range of valid
+values and circumstances in which this can be used, see the Duo Admin API
+documentation.  Optional.
+
+=item ip_whitelist_enroll_policy
+
+What to do after a new user from a trusted IP completes primary
+authentication.  See the L</ip_whitelist_enroll_policy()> method below for
+the possible values.  Optional and defaults to C<enforce>.
+
+=item name
+
+The name of the integration.  Required.
+
+=item notes
+
+Any further description of the integration.  Optional.
+
+=item trusted_device_days
+
+Number of days to allow a user to trust the device they are logging in
+with.  This can only be set for certain integrations and must be between 0
+and 60.  (0 disables this feature.)  For the circumstances in which this
+can be used, see the Duo Admin API documentation.  Optional.
+
+=item type
+
+The type of the integration.  For a list of valid values, see the Duo
+Admin API documentation.  Required.
+
+=item username_normalization_policy
+
+Controls whether or not usernames should be altered before trying to match
+them to a user account.  See the L</username_normalization_policy()> method
+below for the possible values.  Optional and defaults to C<simple>.
+
+=item visual_style
+
+Look and feel of web content generated by the integration.  This can only
+be set for certain integrations.  For a list of valid values and
+circumstances in which this can be used, see the Duo Admin API
+documentation.  Optional.
+
+=back
+
+=item new(DUO, DATA)
+
+Creates a new Net::Duo::Admin::Integration object from a full data set.
+DUO is the Net::Duo object that should be used for any further actions on
+this object.  DATA should be the data structure returned by the Duo REST
+API for a single user, after JSON decoding.  This constructor is primarily
+used internally by other Net::Duo::Admin methods.
+
+=item new(DUO, KEY)
+
+Creates a new Net::Duo::Admin::Integration object from the integration
+key.  DUO is the Net::Duo object that is used to retrieve the integration
+from Duo and will be used for any subsequent operations.  The KEY should
+be the integration key of the integration.  This constructor is
+distinguished from the previous constructor by checking whether KEY is a
+reference.
+
+=back
+
+=head1 INSTANCE ACTION METHODS
+
+=over 4
+
+=item delete()
+
+Delete this integration from Duo.  After successful completion of this
+call, the Net::Duo::Admin::Integration object should be considered
+read-only, since no further changes to the object can be meaningfully sent
+to Duo.
+
+=item json()
+
+Convert the data stored in the object to JSON and return the results.  The
+resulting JSON should match the JSON that one would get back from the Duo
+web service when retrieving the same object (plus any changes made locally
+to the object via set_*() methods).  This is primarily intended for
+debugging dumps or for passing Duo objects to other systems via further
+JSON APIs.
+
+=back
+
+=head1 INSTANCE DATA METHODS
+
+=over 4
+
+=item adminapi_admins()
+
+Whether this admin integration may use all Admin API methods.
+
+=item adminapis_info()
+
+Whether this admin integration may use all Admin API account info methods.
+
+=item adminapis_integrations()
+
+Whether this admin integration may use all Admin API integration methods.
+
+=item adminapis_read_log()
+
+Whether this admin integration may use all Admin API log methods.
+
+=item adminapis_read_resource()
+
+Whether this admin integration may use all Admin API methods that retrieve
+objects such as users, phones, and hardware tokens.
+
+=item adminapis_settings()
+
+Whether this admin integration may use all Admin API settings methods.
+
+=item adminapis_write_resource()
+
+Whether this admin integration may use all Admin API methods that create
+or modify objects such as as users, phones, and hardware tokens.
+
+=item enroll_policy()
+
+What to do after an enrolled user passes primary authentication.  The
+value will be one of C<enroll>, to prompt the user to enroll, C<allow>, to
+allow the user to sign in without presenting an additional factor, and
+C<deny>, to deny authentication for this user.
+
+=item greeting()
+
+Voice greeting read before the authentication instructions to users who
+authenticate with a phone callback.
+
+=item groups_allowed()
+
+A reference to an array of group IDs that are allowed to authenticate with
+the integration.
+
+=item ip_whitelist()
+
+List of trusted IPs or IP ranges.  Ranges may be in the form of CIDR
+network blocks or ranges specified by two IP addresses separated by a dash
+(C<->) are supported.  Authentications from these IP addresses will not
+require a second factor.  Example values:
+
+    192.0.2.8
+    198.51.100.0-198.51.100.20
+    203.0.113.0/24
+
+This is only supported with certain integration types.
+
+=item ip_whitelist_enroll_policy()
+
+What to do after a new user from a trusted IP completes primary
+authentication.  The value will be either C<enforce>, meaning that the
+user will be subject to the normal enrollment policy as returned by
+enroll_policy(), or C<allow>, which means that the user will be
+successfully authenticated without being required to enroll, skipping any
+enrollment policy.
+
+=item integration_key()
+
+The identifier of this integration.  For C<adminapi>, C<accountsapi>,
+C<rest>, and C<verify> integrations, this is the key used as the
+C<integration_key> value when constructing a Net::Duo object.
+
+=item name()
+
+The name of the integration.
+
+=item notes()
+
+Any further description of the integration.
+
+=item secret_key()
+
+Secret used when configuring systems to use this integration.  For
+C<adminapi>, C<accountsapi>, C<rest>, and C<verify> integrations, this is
+the key used as the C<secret_key> value when constructing a Net::Duo
+object.  This is equivalent to a password and should be treated with the
+same care.
+
+=item trusted_device_days()
+
+Number of days to allow a user to trust the device they are logging in
+with, or C<0> if this is disabled.  This setting only has an effect for
+certain integrations.
+
+=item type()
+
+The type of the integration.  For a list of possible values, see the Duo
+Admin API documentation.
+
+=item username_normalization_policy()
+
+Controls whether or not usernames should be altered before trying to match
+them to a user account.  The value will be either C<none>, indicating no
+normalization, or C<simple>, in which C<DOMAIN\username> and
+C<username at example.com> will be converted to C<username> before
+authentication is attempted.
+
+=item visual_style()
+
+Look and feel of web content generated by the integration.  This only has
+an effect for some integrations.  For a list of valid values, see the Duo
+Admin API documentation.
+
+=back
+
+=head1 AUTHOR
+
+Russ Allbery <rra at cpan.org>
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2014 The Board of Trustees of the Leland Stanford Junior
+University
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+=head1 SEE ALSO
+
+L<Net::Duo::Admin>
+
+L<Duo Admin API for integrations|https://www.duosecurity.com/docs/adminapi#integrations>
+
+=cut
diff --git a/lib/Net/Duo/Admin/Phone.pm b/lib/Net/Duo/Admin/Phone.pm
index 76decff..51c7004 100644
--- a/lib/Net/Duo/Admin/Phone.pm
+++ b/lib/Net/Duo/Admin/Phone.pm
@@ -163,7 +163,7 @@ about a phone.
 
 =item create(DUO, DATA)
 
-Creates a new phone in Duo and returns the resulting user as a new
+Creates a new phone in Duo and returns the resulting phone as a new
 Net::Duo::Admin::Phone object.  DUO is the Net::Duo object that should be
 used to perform the creation.  DATA is a reference to a hash with the
 following keys:
diff --git a/lib/Net/Duo/Admin/Token.pm b/lib/Net/Duo/Admin/Token.pm
index 4550053..2515489 100644
--- a/lib/Net/Duo/Admin/Token.pm
+++ b/lib/Net/Duo/Admin/Token.pm
@@ -29,6 +29,23 @@ sub _fields {
 # Install our accessors.
 Net::Duo::Admin::Token->install_accessors;
 
+# Override the new method to support creating a token from an ID instead
+# of decoded JSON data.
+#
+# $class      - Class of object to create
+# $duo        - Net::Duo object to use to create the object
+# $id_or_data - Token ID or reference to data
+#
+# Returns: Newly-created object
+#  Throws: Net::Duo::Exception on any problem creating the object
+sub new {
+    my ($class, $duo, $id_or_data) = @_;
+    if (!ref($id_or_data)) {
+        $id_or_data = $duo->call_json('GET', "/admin/v1/tokens/$id_or_data");
+    }
+    return $class->SUPER::new($duo, $id_or_data);
+}
+
 # Override the create method to add the appropriate URI.
 #
 # $class    - Class of object to create
@@ -135,6 +152,14 @@ the Net::Duo object that should be used for any further actions on this
 object.  DATA should be the data structure returned by the Duo REST API
 for a single user, after JSON decoding.
 
+=item new(DUO, ID)
+
+Creates a new Net::Duo::Admin::Token by ID.  DUO is the Net::Duo object
+that is used to retrieve the token from Duo and will be used for any
+subsequent operations.  The ID should be the Duo identifier of the token.
+This constructor is distinguished from the previous constructor by
+checking whether ID is a reference.
+
 =back
 
 =head1 INSTANCE ACTION METHODS
diff --git a/lib/Net/Duo/Auth.pm b/lib/Net/Duo/Auth.pm
index 7ed6b94..a7b0c92 100644
--- a/lib/Net/Duo/Auth.pm
+++ b/lib/Net/Duo/Auth.pm
@@ -15,92 +15,184 @@ use warnings;
 
 use parent qw(Net::Duo);
 
+use Carp qw(croak);
+use Net::Duo::Auth::Async;
+use URI::Escape qw(uri_escape_utf8);
+
+# All dies are of constructed objects, which perlcritic misdiagnoses.
+## no critic (ErrorHandling::RequireCarping)
+
 ##############################################################################
 # Auth API methods
 ##############################################################################
 
-# Confirm that authentication works properly.
+# Helper function to validate and canonicalize arguments to the auth and
+# auth_async functions.  Ensures that the arguments meet the calling contract
+# for the auth method (see below) and returns a reference to a new hash with
+# the canonicalized copy of data.
 #
-# $self - The Net::Duo::Auth object
+# $self     - The Net::Duo::Auth object
+# $args_ref - Reference to hash of arguments to an auth function
 #
-# Returns: Server time in seconds since UNIX epoch
-#  Throws: Net::Duo::Exception on failure
-sub check {
-    my ($self) = @_;
-    my $result = $self->call_json('GET', '/auth/v2/check');
-    return $result->{time};
+# Returns: Reference to hash of canonicalized arguments
+#  Throws: Text exception on internal call method error
+sub _canonicalize_auth_args {
+    my ($self, $args_ref) = @_;
+    my %args = %{$args_ref};
+
+    # Ensure we have either username or user_id, but not neither or both.
+    my $user_count = grep { defined($args{$_}) } qw(username user_id);
+    if ($user_count < 1) {
+        croak('no username or user_id specified');
+    } elsif ($user_count > 1) {
+        croak('username and user_id both given');
+    }
+
+    # Ensure factor is set.
+    if (!defined($args{factor})) {
+        croak('no factor specified');
+    }
+
+    # Set some defaults that we provide in our API guarantee.
+    my $factor = $args{factor};
+    if ($factor eq 'push' || $factor eq 'phone' || $factor eq 'auto') {
+        $args{device} //= 'auto';
+    }
+
+    # Convert pushinfo to a URL-encoded string if it is present.  We use this
+    # logic rather than _canonicalize_args so that we can preserve order.
+    if ($args{pushinfo}) {
+        my @pushinfo = @{ $args{pushinfo} };
+        my @pairs;
+        while (@pushinfo) {
+            my $encoded_key   = uri_escape_utf8(shift(@pushinfo));
+            my $encoded_value = uri_escape_utf8(shift(@pushinfo));
+            my $pair          = $encoded_key . q{=} . $encoded_value;
+            push(@pairs, $pair);
+        }
+        $args{pushinfo} = join(q{&}, @pairs);
+    }
+
+    # Return the results.  Currently, we don't validate any of the other
+    # arguments and just pass them straight to Duo.  We could do better about
+    # this.
+    return \%args;
 }
 
-# Send one or more passcodes (depending on Duo configuration) to a user via
-# SMS.
+# Perform a synchronous user authentication.  The user will be authenticated
+# given the factor and additional information provided in the $args argument.
+# The call will not return until the user has authenticated or the call has
+# failed for some reason.  To do long-polling instead, see the auth_async
+# method.
 #
 # $self     - The Net::Duo::Auth object
-# $username - The username to send SMS passcodes to
+# $args_ref - Reference to hash of arguments, chosen from:
+#   user_id  - ID of user (either this or username is required)
+#   username - Username of user (either this or user_id is required)
+#   factor   - One of auto, push, passcode, or phone
+#   ipaddr   - IP address of user (optional)
+# For factor == push:
+#   device           - ID of the device (optional, default is "auto")
+#   type             - String to display before prompt (optional)
+#   display_username - String instead of username (optional)
+#   pushinfo         - Reference to array of pairs to show user (optional)
+# For factor == passcode:
+#   passcode - The passcode to validate
+# For factor == phone:
+#   device - The ID of the device to call (optional, default is "auto")
 #
-# Returns: Status of the request.  Will be 'sent' on success
+# Returns: Scalar context: true if user was authenticated, false otherwise
+#          List context: true/false for success, then hash of additional data
+#            status               - Status of authentication
+#            status_msg           - Detailed status message
+#            trusted_device_token - Token to use later for /preauth
 #  Throws: Net::Duo::Exception on failure
-sub sms_passcodes {
-    my ($self, $username) = @_;
-    my $data = {
-        username => $username,
-        factor   => 'sms',
-        device   => 'auto',
-    };
-    my $result = $self->call_json('POST', '/auth/v2/auth', $data);
-    return $result->{status};
+sub auth {
+    my ($self, $args_ref) = @_;
+    my $args = $self->_canonicalize_auth_args($args_ref);
+
+    # Make the call to Duo.
+    my $result = $self->call_json('POST', '/auth/v2/auth', $args);
+
+    # Ensure we got a valid result.
+    if (!defined($result->{result})) {
+        my $error = 'no authentication result from Duo';
+        die Net::Duo::Exception->protocol($error, $result);
+    } elsif ($result->{result} ne 'allow' && $result->{result} ne 'deny') {
+        my $error = "invalid authentication result $result->{result}";
+        die Net::Duo::Exception->protocol($error, $result);
+    }
+
+    # Determine whether the authentication succeeded, and return the correct
+    # results.
+    my $success = $result->{result} eq 'allow';
+    delete $result->{result};
+    return wantarray ? ($success, $result) : $success;
 }
 
-# Validate an out-of-band method, such as Duo Push or a phone call.
+# Perform an asynchronous authentication.
+#
+# Takes the same arguments as the auth method, but starts an asynchronous
+# authentication.  Returns a transaction ID, which can be passed to
+# auth_status() to long-poll the result of the authentication.
 #
 # $self     - The Net::Duo::Auth object
-# $username - The username to attempt an auth for
+# $args_ref - Reference to hash of arguments, chosen from:
 #
-# Returns: Transaction id, which can be checked on with auth_status
+# Returns: The transaction ID to poll with auth_status()
 #  Throws: Net::Duo::Exception on failure
-sub validate_out_of_band {
-    my ($self, $username) = @_;
-    my $data = {
-        username => $username,
-        factor   => 'auto',
-        device   => 'auto',
-        async    => 1,
-    };
-    my $result = $self->call_json('POST', '/auth/v2/auth', $data);
-    return $result->{txid};
+sub auth_async {
+    my ($self, $args_ref) = @_;
+    my $args = $self->_canonicalize_auth_args($args_ref);
+    $args->{async} = 1;
+
+    # Make the call to Duo.
+    my $result = $self->call_json('POST', '/auth/v2/auth', $args);
+
+    # Return the transaction ID.
+    if (!defined($result->{txid})) {
+        my $error = 'no transaction ID in response to async auth call';
+        die Net::Duo::Exception->protocol($error, $result);
+    }
+    return Net::Duo::Auth::Async->new($self, $result->{txid});
 }
 
-# Validate a passcode given to us by a user.
+# Confirm that authentication works properly.
 #
-# $self     - The Net::Duo::Auth object
-# $username - The username to check against
-# $passcode - The passcode given by the user
+# $self - The Net::Duo::Auth object
 #
-# Returns: Status of the auth.  Will be 'allow' on success.
+# Returns: Server time in seconds since UNIX epoch
 #  Throws: Net::Duo::Exception on failure
-sub validate_passcode {
-    my ($self, $username, $passcode) = @_;
-    my $data = {
-        username => $username,
-        factor   => 'passcode',
-        passcode => $passcode,
-    };
-    my $result = $self->call_json('POST', '/auth/v2/auth', $data);
-    return $result->{status};
+sub check {
+    my ($self) = @_;
+    my $result = $self->call_json('GET', '/auth/v2/check');
+    return $result->{time};
 }
 
-# Check on the current status of a user auth request that requires a user
-# response, such as a phone call or Duo Push.
+# Send one or more passcodes (depending on Duo configuration) to a user via
+# SMS.  This should always succeed, so any error results in an exception.
 #
-# $self           - The Net::Duo::Auth object
-# $transaction_id - Transaction id for the auth, given at auth attempt
+# $self     - The Net::Duo::Auth object
+# $username - The username to send SMS passcodes to
+# $device   - ID of the device to which to send passcodes (optional)
 #
-# Returns: Status of the auth.  Will be 'allow' on success.
+# Returns: undef
 #  Throws: Net::Duo::Exception on failure
-sub auth_status {
-    my ($self, $transaction_id) = @_;
-    my $data = { txid => $transaction_id };
-    my $result = $self->call_json('GET', '/auth/v2/auth_status', $data);
-    return $result->{result};
+sub send_sms_passcodes {
+    my ($self, $username, $device) = @_;
+    my $data = {
+        username => $username,
+        factor   => 'sms',
+        device   => $device // 'auto',
+    };
+    my $result = $self->call_json('POST', '/auth/v2/auth', $data);
+    if ($result->{status} ne 'sent') {
+        my $status  = $result->{status};
+        my $message = $result->{status_msg};
+        my $error   = "sending SMS passcodes returned $status: $message";
+        die Net::Duo::Exception->protocol($error, $result);
+    }
+    return;
 }
 
 1;
@@ -108,7 +200,7 @@ __END__
 
 =for stopwords
 Allbery Auth MERCHANTABILITY NONINFRINGEMENT sublicense SMS passcode
-passcodes
+passcodes ipaddr pushinfo
 
 =head1 NAME
 
@@ -164,14 +256,143 @@ documentation of the possible arguments.
 
 =over 4
 
-=item auth_status(ID)
+=item auth(ARGS)
+
+Perform a Duo synchronous authentication.
+
+The user will be authenticated given the factor and additional information
+provided in ARGS.  The call will not return until the user has
+authenticated or the call has failed for some reason.  To do long-polling
+instead, see auth_async().
+
+ARGS should be a reference to a hash.  The following keys may always be
+present:
+
+=over 4
+
+=item user_id
+
+The Duo ID of the user to authenticate.  Either this or C<username>, but
+only one or the other, must be specified.
+
+=item username
+
+The username of the user to authenticate.  Either this or C<username>, but
+only one or the other, must be specified.
+
+=item factor
+
+The authentication factor to use, chosen from C<push>, C<passcode>, or
+C<phone>, or C<auto> to use whichever of C<push> or C<phone> appears best
+for this user's devices according to Duo.  Required.
+
+=item ipaddr
+
+The IP address of the user, used for logging and to support sending an
+C<allow> response without further verification if the user is coming from
+a trusted network as configured in the integration.
+
+=back
+
+Additional keys may be present depending on C<factor>.  For a C<factor>
+value of C<push>:
+
+=over 4
+
+=item device
+
+The ID of the device to which to send the push notification, or C<auto> to
+send to the first push-capable device.  Optional, defaulting to C<auto>.
+
+=item type
+
+This string is displayed in the Duo Mobile app before the word C<request>.
+The default is C<Login>, so the phrase C<Login request> appears in the
+push notification text and on the request details screen.  You may want to
+specify C<Transaction>, C<Transfer>, etc.  Optional.
+
+=item display_username
+
+String to display in Duo Mobile in place of the user's Duo username.
+Optional.
+
+=item pushinfo
+
+A reference to a list of additional key/value pairs to display to the user
+as part of the authentication request.  For example:
+
+    { pushinfo => [from => 'login portal', domain => 'example.com'] }
+
+This is a list rather than a hash so that it preserves the order of
+arguments, but there should always be an even number of members in the
+list.
+
+=back
+
+For a C<factor> value of C<passcode>:
+
+=over 4
+
+=item passcode
+
+The passcode to validate.  Required.
+
+=back
 
-Calls the Duo C<auth_status> endpoint.  This is used to check the current
-status of an authentication attempt that cannot be immediately verified,
-such as a Duo Push or phone call.  On success, it returns the current
-result for the authentication attempt.  C<allow> or C<deny> are simple
-success or failure, but C<waiting> denotes the attempt still being in
-progress and so the calling program should continue to check back.
+For a C<factor> value of C<phone>:
+
+=over 4
+
+=item phone
+
+The ID of the device to call, or C<auto> to call the first available
+device.  Optional and defaults to C<auto>.
+
+=back
+
+In a scalar context, this method returns true if the user was successfully
+authenticated and false if authentication failed for any reason.  In a
+list context, the same status argument is returned as the first member of
+the list, and the second member of the list will be a reference to a hash
+of additional data.  Possible keys are:
+
+=over 4
+
+=item status
+
+String detailing the progress or outcome of the authentication attempt.
+
+=item status_msg
+
+A string describing the result of the authentication attempt.  If the
+authentication attempt was denied, it may identify a reason.  This string
+is intended for display to the user.
+
+=item trusted_device_token
+
+If the trusted devices option is enabled for this account, returns a token
+for a trusted device that can later be passed to the Duo C<preauth>
+endpoint.
+
+=back
+
+If you are looking for the Duo C<sms> factor type, use the
+send_sms_passcodes() method instead.
+
+=item auth_async(ARGS)
+
+Perform a Duo asynchronous authentication.
+
+An authentication attempt will be started for a user according to the
+information provided in ARGS.  The return value from this call will be a
+Net::Duo::Admin::Async object, which provides a status() method, to get
+the status of the authentication, and an id() method, to recover the
+underlying transaction ID.  This approach allows the application to get
+the authentication status at each stage, instead of receiving no
+information until the authentication has succeeded or failed.
+
+ARGS should be a reference to a hash with the same parameters as were
+specified for the L</auth()> method.
 
 =item check()
 
@@ -180,27 +401,12 @@ all of the integration arguments are correct and the client can
 authenticate to the Duo authentication API.  On success, it returns the
 current time on the Duo server in seconds since UNIX epoch.
 
-=item sms_passcodes(USERID)
-
-Calls the Duo C<auth> endpoint but without performing an authentication,
-just telling Duo to send a new batch of passcodes via SMS.  The user's
-first SMS-capable phone is used unconditionally.  On success, it returns
-the status of the call.
-
-=item validate_out_of_band(USERID)
-
-Calls the Duo C<auth> endpoint and requests an asynchronous
-authentication.  This requests an out-of-band user authentication attempt
-(Duo Push or phone call), that will require the user to respond on their
-phone or device.  On success, it will return the transaction ID for the
-authentication attempt.  This can be used with auth_status() later to
-determine the final outcome of the authentication.
-
-=item validate_passcode(USERID, PASSCODE)
+=item send_sms_passcodes(USERNAME[, DEVICE])
 
-Calls the Duo C<auth> endpoint.  This attempts to validate a passcode
-against a user account to see if the user can successfully log in.  On
-success, it returns the status of the authentication attempt from Duo.
+Send a new batch of passcodes to the specified user via SMS.  By default,
+the passcodes will be sent to the first SMS-capable device (the Duo
+C<auto> behavior).  The optional second argument specifies a device ID to
+which to send the passcodes.  Any failure will result in an exception.
 
 =back
 
diff --git a/lib/Net/Duo/Auth/Async.pm b/lib/Net/Duo/Auth/Async.pm
new file mode 100644
index 0000000..7c727eb
--- /dev/null
+++ b/lib/Net/Duo/Auth/Async.pm
@@ -0,0 +1,188 @@
+# Class representing an asynchronous Duo authentication.
+#
+# This class wraps the transaction ID returned by Duo from an asynchronous
+# authentication and provides a method to long-poll the status of that
+# authentication attempt.
+
+package Net::Duo::Auth::Async 1.00;
+
+use 5.014;
+use strict;
+use warnings;
+
+use Net::Duo;
+
+# All dies are of constructed objects, which perlcritic misdiagnoses.
+## no critic (ErrorHandling::RequireCarping)
+
+# Create a new Net::Duo::Auth::Async object from the transaction ID and a
+# Net::Duo object.
+#
+# $class - Class of object to create
+# $duo   - Net::Duo object to use for calls
+# $id    - The transaction ID of an asynchronous transaction
+#
+# Returns: Newly-created object
+#  Throws: Net::Duo::Exception on any problem creating the object
+sub new {
+    my ($class, $duo, $id) = @_;
+    my $self = { _duo => $duo, id => $id };
+    bless($self, $class);
+    return $self;
+}
+
+# Return the transaction ID.
+#
+# $self - The Net::Duo::Auth::Async object
+#
+# Returns: The underlying transaction ID
+sub id {
+    my ($self) = @_;
+    return $self->{id};
+}
+
+# Check on the current status of an asynchronous authentication.  This uses
+# long polling, meaning that the call returns for every status change,
+# but does not otherwise have a timeout.
+#
+# $self - The Net::Duo::Auth::Async object
+#
+# Returns: Scalar context: the current status
+#          List context: list of current status and reference to hash of data
+#  Throws: Net::Duo::Exception on failure
+sub status {
+    my ($self) = @_;
+
+    # Make the Duo call.
+    my $data   = { txid => $self->{id} };
+    my $uri    = '/auth/v2/auth_status';
+    my $result = $self->{_duo}->call_json('GET', $uri, $data);
+
+    # Ensure the response included a result field.
+    if (!defined($result->{result})) {
+        my $error = 'no authentication result from Duo';
+        die Net::Duo::Exception->protocol($error, $result);
+    }
+    my $status = $result->{result};
+    delete $result->{result};
+
+    # Return the result as appropriate for context.
+    return wantarray ? ($status, $result) : $status;
+}
+
+1;
+__END__
+
+=for stopwords
+Allbery Auth MERCHANTABILITY NONINFRINGEMENT async sublicense
+
+=head1 NAME
+
+Net::Duo::Auth::Async - Representation of an asynchronous Duo authentication
+
+=head1 SYNOPSIS
+
+    use 5.010;
+
+    my $config = get_config();
+    my $duo = Net::Duo::Auth->new($config);
+    my $async = $duo->auth_async({ username => 'user', factor => 'auto' });
+    say scalar($async->status);
+
+=head1 DESCRIPTION
+
+Net::Duo::Auth::Async represents an open asynchronous authentication
+attempt with Duo.  It's a wrapper around the Duo async transaction ID and
+the method to check on the status of that transaction.  This object can
+either be created directly from a stored transaction ID or is returned by
+the auth_async() method of Net::Duo::Auth.
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item new(DUO, ID)
+
+Create a new Net::Duo::Auth::Async object from a Net::Duo object and a
+transaction.  This should be used to recreate the Net::Duo::Auth::Async
+object if the transaction ID were handed off to some other process that
+then asks for status later.
+
+=back
+
+=head1 INSTANCE METHODS
+
+=over 4
+
+=item id()
+
+Returns the transaction ID represented by this object.  This transaction
+ID can be used later to recreate the object.
+
+=item status()
+
+Returns the status of the authentication.
+
+In scalar context, returns only the status, which will be one of C<allow>,
+C<deny>, and C<waiting>.  C<waiting> indicates that the authentication is
+still in progress and has not yet completed.
+
+In list context, returns the same status as the first element and a
+reference to a hash of additional information as the second element.  The
+hash will have one or more of the following keys:
+
+=over 4
+
+=item status
+
+String detailing the progress or outcome of the authentication attempt.
+See the Duo Auth API documentation for a complete list of possible values.
+
+=item status_msg
+
+A string describing the result of the authentication attempt.  If the
+authentication attempt was denied, it may identify a reason.  This string
+is intended for display to the user.
+
+=item trusted_device_token
+
+If the trusted devices option is enabled for this account, returns a token
+for a trusted device that can later be passed to the Duo C<preauth>
+endpoint.
+
+=back
+
+=back
+
+=head1 AUTHOR
+
+Russ Allbery <rra at cpan.org>
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2014 The Board of Trustees of the Leland Stanford Junior
+University
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+=head1 SEE ALSO
+
+L<Duo Auth API|https://www.duosecurity.com/docs/authapi>
+
+=cut
diff --git a/lib/Net/Duo/Object.pm b/lib/Net/Duo/Object.pm
index 5be0a6a..d8dd2d1 100644
--- a/lib/Net/Duo/Object.pm
+++ b/lib/Net/Duo/Object.pm
@@ -128,10 +128,12 @@ sub create {
     # Make a copy of the data and convert all boolean values.
     my %data = %{$data_ref};
   FIELD:
-    for my $field (keys %{$fields}) {
+    for my $field (keys %data) {
         my ($type, $flags) = _field_type($fields->{$field});
         if ($flags->{boolean}) {
             $data{$field} = $data{$field} ? 'true' : 'false';
+        } elsif ($flags->{zero_or_one}) {
+            $data{$field} = $data{$field} ? 1 : 0;
         }
     }
 
@@ -172,6 +174,8 @@ sub commit {
         my ($type, $flags) = _field_type($fields->{$field});
         if ($flags->{boolean}) {
             $data{$field} = $self->{$field} ? 'true' : 'false';
+        } elsif ($flags->{zero_or_one}) {
+            $data{$field} = $self->{$field} ? 1 : 0;
         } else {
             $data{$field} = $self->{$field};
         }
@@ -284,6 +288,8 @@ sub json {
         if ($type eq 'simple' || $type eq 'array') {
             if ($flags->{boolean}) {
                 $data{$field} = $self->{$field} ? 'true' : 'false';
+            } elsif ($flags->{zero_or_one}) {
+                $data{$field} = $self->{$field} ? 1 : 0;
             } else {
                 $data{$field} = $self->{$field};
             }
@@ -397,6 +403,12 @@ the field but prefixed with C<set_> that will set the value of that field
 and remember that it's been changed locally.  Changed fields will then
 be pushed back to Duo via the commit() method.
 
+=item C<zero_or_one>
+
+This is a boolean field that wants values of 0 or 1.  Convert all values
+to C<1> or C<0> before sending the data to Duo.  Only makes sense with a
+field of type C<simple>.
+
 =back
 
 =head1 CLASS METHODS
diff --git a/t/admin/groups.t b/t/admin/groups.t
old mode 100644
new mode 100755
diff --git a/t/admin/integrations.t b/t/admin/integrations.t
new file mode 100755
index 0000000..afb5830
--- /dev/null
+++ b/t/admin/integrations.t
@@ -0,0 +1,136 @@
+#!/usr/bin/perl
+#
+# Test suite for integration handling in the Admin API.
+#
+# Written by Russ Allbery <rra at cpan.org>
+# Copyright 2014
+#     The Board of Trustees of the Leland Stanford Junior University
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+use 5.014;
+use strict;
+use warnings;
+
+use lib 't/lib';
+
+use JSON;
+use Perl6::Slurp;
+use Test::Mock::Duo::Agent;
+use Test::More;
+use Test::RRA::Duo qw(is_admin_integration);
+
+BEGIN {
+    use_ok('Net::Duo::Admin');
+}
+
+# Create a JSON decoder.
+my $json = JSON->new->utf8(1);
+
+# Arguments for the Net::Duo constructor.
+my %args = (key_file => 't/data/integrations/admin.json');
+
+# Create the Net::Duo::Auth object with our testing integration configuration
+# and a mock agent.
+my $mock = Test::Mock::Duo::Agent->new(\%args);
+$args{user_agent} = $mock;
+my $duo = Net::Duo::Admin->new(\%args);
+isa_ok($duo, 'Net::Duo::Admin');
+
+# Try an integrations call, returning the test user data.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/integrations',
+        response_file => 't/data/responses/integrations.json',
+    }
+);
+note('Testing integrations endpoint');
+my @integrations = $duo->integrations;
+
+# Should be an array of a single integration
+is(scalar(@integrations), 1, 'integrations method returns one object');
+my $integration = $integrations[0];
+
+# Verify that the returned integration is correct.
+my $raw      = slurp('t/data/responses/integrations.json');
+my $expected = $json->decode($raw)->[0];
+is_admin_integration($integration, $expected);
+
+# Create a new integration.  Make sure we include some zero_or_one fields.
+my $data = {
+    name              => 'Test admin integration',
+    type              => 'adminapi',
+    adminapi_admins   => 0,
+    adminapi_info     => 'true',
+    adminapi_read_log => 1,
+};
+
+# Set up the mock for creating a new integration.  It will see the converted
+# zero_or_one values.
+my $post_data = { %{$data} };
+$post_data->{adminapi_info} = 1;
+$mock->expect(
+    {
+        method        => 'POST',
+        uri           => '/admin/v1/integrations',
+        content       => $post_data,
+        response_file => 't/data/responses/integration.json',
+    }
+);
+
+# Attempt the create call.
+note('Testing integration create endpoint');
+$integration = Net::Duo::Admin::Integration->create($duo, $data);
+
+# Verify that the returned group is correct.  (Just use the same return data.)
+$raw      = slurp('t/data/responses/integration.json');
+$expected = $json->decode($raw);
+is_admin_integration($integration, $expected);
+
+# Convert the full integration to JSON and compare that with the expected
+# JSON.
+is_deeply($json->decode($integration->json), $expected, 'Full JSON output');
+
+# Retrieve an integration by key.
+my $key = $integration->integration_key;
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => "/admin/v1/integrations/$key",
+        response_file => 't/data/responses/integration.json',
+    }
+);
+note('Testing integration retrieval by key');
+$integration = Net::Duo::Admin::Integration->new($duo, $key);
+is_admin_integration($integration, $expected);
+
+# Delete that integration.
+$mock->expect(
+    {
+        method        => 'DELETE',
+        uri           => "/admin/v1/integrations/$key",
+        response_data => q{},
+    }
+);
+note('Testing integration delete endpoint');
+$integration->delete;
+
+# Finished.  Tell Test::More that.
+done_testing();
diff --git a/t/admin/misc.t b/t/admin/misc.t
new file mode 100755
index 0000000..48a8109
--- /dev/null
+++ b/t/admin/misc.t
@@ -0,0 +1,131 @@
+#!/usr/bin/perl
+#
+# Test suite for other Duo Admin API methods.
+#
+# Written by Russ Allbery <rra at cpan.org>
+# Copyright 2014
+#     The Board of Trustees of the Leland Stanford Junior University
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+use 5.014;
+use strict;
+use warnings;
+
+use lib 't/lib';
+
+use JSON;
+use Perl6::Slurp;
+use Test::Mock::Duo::Agent;
+use Test::More;
+
+BEGIN {
+    use_ok('Net::Duo::Admin');
+}
+
+# Create a JSON decoder.
+my $json = JSON->new->utf8(1);
+
+# Arguments for the Net::Duo constructor.
+my %args = (key_file => 't/data/integrations/admin.json');
+
+# Create the Net::Duo::Auth object with our testing integration configuration
+# and a mock agent.
+my $mock = Test::Mock::Duo::Agent->new(\%args);
+$args{user_agent} = $mock;
+my $duo = Net::Duo::Admin->new(\%args);
+isa_ok($duo, 'Net::Duo::Admin');
+
+# Test retrieving administrator logs.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/logs/administrator',
+        response_file => 't/data/responses/log-admin.json',
+    }
+);
+my @logs     = $duo->logs_administrator;
+my $raw      = slurp('t/data/responses/log-admin.json');
+my $expected = $json->decode($raw);
+is_deeply([@logs], $expected, 'Administrator logs');
+
+# The same with a minimum time.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/logs/administrator',
+        content       => { mintime => 1_403_053_795 },
+        response_file => 't/data/responses/log-admin.json',
+    }
+);
+ at logs = $duo->logs_administrator(1_403_053_795);
+is_deeply([@logs], $expected, 'Administrator logs with mintime');
+
+# Test retrieving authentication logs.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/logs/authentication',
+        response_file => 't/data/responses/log-auth.json',
+    }
+);
+ at logs     = $duo->logs_authentication;
+$raw      = slurp('t/data/responses/log-auth.json');
+$expected = $json->decode($raw);
+is_deeply([@logs], $expected, 'Authentication logs');
+
+# The same with a minimum time.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/logs/authentication',
+        content       => { mintime => 1_403_053_795 },
+        response_file => 't/data/responses/log-auth.json',
+    }
+);
+ at logs = $duo->logs_authentication(1_403_053_795);
+is_deeply([@logs], $expected, 'Authentication logs with mintime');
+
+# Test retrieving telephony logs.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/logs/telephony',
+        response_file => 't/data/responses/log-telephony.json',
+    }
+);
+ at logs     = $duo->logs_telephony;
+$raw      = slurp('t/data/responses/log-telephony.json');
+$expected = $json->decode($raw);
+is_deeply([@logs], $expected, 'Telephony logs');
+
+# The same with a minimum time.
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/admin/v1/logs/telephony',
+        content       => { mintime => 1_403_053_795 },
+        response_file => 't/data/responses/log-telephony.json',
+    }
+);
+ at logs = $duo->logs_telephony(1_403_053_795);
+is_deeply([@logs], $expected, 'Telephony logs with mintime');
+
+# Finished.  Tell Test::More that.
+done_testing();
diff --git a/t/admin/phones.t b/t/admin/phones.t
index 34e21b5..439b166 100755
--- a/t/admin/phones.t
+++ b/t/admin/phones.t
@@ -130,7 +130,7 @@ $mock->expect(
         response_file => 't/data/responses/phone-create.json',
     }
 );
-note('Testing phone creation by ID');
+note('Testing phone retrieval by ID');
 $phone = Net::Duo::Admin::Phone->new($duo, $id);
 is_admin_phone($phone, $expected);
 
diff --git a/t/admin/tokens.t b/t/admin/tokens.t
old mode 100644
new mode 100755
diff --git a/t/auth/auth.t b/t/auth/auth.t
new file mode 100755
index 0000000..d1378b2
--- /dev/null
+++ b/t/auth/auth.t
@@ -0,0 +1,169 @@
+#!/usr/bin/perl
+#
+# Test suite for the Auth API auth functions.
+#
+# Written by Jon Robertson <jonrober at stanford.edu>
+# Copyright 2014
+#     The Board of Trustees of the Leland Stanford Junior University
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+use 5.014;
+use strict;
+use warnings;
+
+use lib 't/lib';
+
+use Test::Mock::Duo::Agent;
+use Test::More;
+
+BEGIN {
+    use_ok('Net::Duo::Auth');
+}
+
+# Arguments for the Net::Duo constructor.
+my %args = (key_file => 't/data/integrations/auth.json');
+
+# Create the Net::Duo::Auth object with our testing integration configuration
+# and a mock agent.
+my $mock = Test::Mock::Duo::Agent->new(\%args);
+$args{user_agent} = $mock;
+my $duo = Net::Duo::Auth->new(\%args);
+isa_ok($duo, 'Net::Duo::Auth');
+
+# Set expected data for a successful validation call.
+note('Testing token validation');
+$mock->expect(
+    {
+        method  => 'POST',
+        uri     => '/auth/v2/auth',
+        content => {
+            username => 'user',
+            passcode => '0123456',
+            factor   => 'passcode',
+        },
+        response_data => {
+            result     => 'allow',
+            status     => 'allow',
+            status_msg => 'Success. Logging you in...',
+        },
+    }
+);
+my $args = {
+    username => 'user',
+    factor   => 'passcode',
+    passcode => '0123456',
+};
+is(scalar($duo->auth($args)), 1, 'Auth returns scalar success');
+
+# Do a Duo Push authentication with some extra data.
+$mock->expect(
+    {
+        method  => 'POST',
+        uri     => '/auth/v2/auth',
+        content => {
+            username => 'user',
+            factor   => 'push',
+            device   => 'auto',
+            pushinfo => 'from=login%20portal&domain=example.com',
+        },
+        response_data => {
+            result     => 'allow',
+            status     => 'allow',
+            status_msg => 'Success. Logging you in...',
+        },
+    }
+);
+$args = {
+    username => 'user',
+    factor   => 'push',
+    device   => 'auto',
+    pushinfo => [from => 'login portal', domain => 'example.com'],
+};
+my ($success, $data) = $duo->auth($args);
+is($success, 1, 'Auth returns list success');
+my $expected = {
+    status     => 'allow',
+    status_msg => 'Success. Logging you in...',
+};
+is_deeply($data, $expected, '...with correct extra information');
+
+# Test an asynchronous authentication.
+note('Testing asynchronous authentication');
+$mock->expect(
+    {
+        method  => 'POST',
+        uri     => '/auth/v2/auth',
+        content => {
+            username => 'user',
+            device   => 'auto',
+            factor   => 'auto',
+            async    => 1,
+        },
+        response_data => { txid => 'id' },
+    }
+);
+my $async = $duo->auth_async({ username => 'user', factor => 'auto' });
+isa_ok($async, 'Net::Duo::Auth::Async', 'return from auth_async');
+is($async->id, 'id', '...with correct transaction ID');
+
+# Out of band auth_status.
+note('Testing authentication status');
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/auth/v2/auth_status',
+        content       => { txid => 'id' },
+        response_data => {
+            result     => 'allow',
+            status     => 'allow',
+            status_msg => 'Success. Logging you in...',
+        },
+    }
+);
+is($async->status, 'allow', 'Async status correct in scalar context');
+$mock->expect(
+    {
+        method        => 'GET',
+        uri           => '/auth/v2/auth_status',
+        content       => { txid => 'id' },
+        response_data => {
+            result     => 'allow',
+            status     => 'allow',
+            status_msg => 'Success. Logging you in...',
+        },
+    }
+);
+my $status;
+($status, $data) = $async->status;
+is($status, 'allow', 'Async status correct in array context');
+$expected = {
+    status     => 'allow',
+    status_msg => 'Success. Logging you in...',
+};
+is_deeply($data, $expected, 'Async data correct in array context');
+
+# Recreate the Net::Duo::Auth::Async object from the transaction ID.
+my $id = $async->id;
+$async = Net::Duo::Auth::Async->new($duo, $id);
+isa_ok($async, 'Net::Duo::Auth::Async', 'return from new');
+is($async->id, 'id', '...with correct transaction ID');
+
+# Finished.  Tell Test::More that.
+done_testing();
diff --git a/t/auth/basic.t b/t/auth/basic.t
index b491c74..b4b3386 100755
--- a/t/auth/basic.t
+++ b/t/auth/basic.t
@@ -30,8 +30,6 @@ use warnings;
 
 use lib 't/lib';
 
-use HTTP::Response;
-use JSON;
 use Test::Mock::Duo::Agent;
 use Test::More;
 
@@ -49,30 +47,15 @@ $args{user_agent} = $mock;
 my $duo = Net::Duo::Auth->new(\%args);
 isa_ok($duo, 'Net::Duo::Auth');
 
-# Create a JSON encoder.
-my $json = JSON->new->utf8(1);
-
-# Set expected data for a check call.
-my $reply = {
-    stat     => 'OK',
-    response => {
-        time => 1_357_020_061,
-    },
-};
-my $response = HTTP::Response->new;
-$response->code(200);
-$response->message('Success');
-$response->content($json->encode($reply));
+# Verify the check call.
+note('Testing check endpoint');
 $mock->expect(
     {
-        method   => 'GET',
-        uri      => '/auth/v2/check',
-        response => $response,
+        method        => 'GET',
+        uri           => '/auth/v2/check',
+        response_data => { time => 1_357_020_061 },
     }
 );
-
-# Make the call and check the response.
-note('Testing check endpoint');
 is($duo->check, 1_357_020_061, 'Decoded /check response is correct');
 
 # Finished.  Tell Test::More that.
diff --git a/t/auth/sms.t b/t/auth/sms.t
index dffa8a1..baf4ea9 100755
--- a/t/auth/sms.t
+++ b/t/auth/sms.t
@@ -30,8 +30,6 @@ use warnings;
 
 use lib 't/lib';
 
-use HTTP::Response;
-use JSON;
 use Test::Mock::Duo::Agent;
 use Test::More;
 
@@ -49,37 +47,44 @@ $args{user_agent} = $mock;
 my $duo = Net::Duo::Auth->new(\%args);
 isa_ok($duo, 'Net::Duo::Auth');
 
-# Create a JSON encoder.
-my $json = JSON->new->utf8(1);
-
-# Set expected data for a successful validation call.
-my $reply = {
-    stat     => 'OK',
-    response => {
-        result => 'deny',
-        status => 'sent',
-    },
-};
-my $response = HTTP::Response->new;
-$response->code(200);
-$response->message('Success');
-$response->content($json->encode($reply));
+# Test sending passcodes to the default device.
+note('Testing sending SMS passcodes to default device');
 $mock->expect(
     {
-        method   => 'POST',
-        uri      => '/auth/v2/auth',
-        response => $response,
-        content  => {
+        method  => 'POST',
+        uri     => '/auth/v2/auth',
+        content => {
             username => 'user',
             factor   => 'sms',
             device   => 'auto',
         },
+        response_data => {
+            result => 'deny',
+            status => 'sent',
+        },
     }
 );
+is($duo->send_sms_passcodes('user'), undef, 'Sent passcodes');
 
-# Make the call and check the response.
-note('Testing token validation');
-is($duo->sms_passcodes('user'), 'sent', 'Decoded /auth response is correct');
+# Now send to an alternate device.
+note('Testing sending SMS passcodes a specific device');
+$mock->expect(
+    {
+        method  => 'POST',
+        uri     => '/auth/v2/auth',
+        content => {
+            username => 'user',
+            factor   => 'sms',
+            device   => 'DPFZRS9FB0D46QFTM891',
+        },
+        response_data => {
+            result => 'deny',
+            status => 'sent',
+        },
+    }
+);
+is($duo->send_sms_passcodes('user', 'DPFZRS9FB0D46QFTM891'),
+    undef, 'Sent passcodes to device');
 
 # Finished.  Tell Test::More that.
 done_testing();
diff --git a/t/auth/validation.t b/t/auth/validation.t
deleted file mode 100755
index 05f9f79..0000000
--- a/t/auth/validation.t
+++ /dev/null
@@ -1,126 +0,0 @@
-#!/usr/bin/perl
-#
-# Test suite for the Auth API validation functions.
-#
-# Written by Jon Robertson <jonrober at stanford.edu>
-# Copyright 2014
-#     The Board of Trustees of the Leland Stanford Junior University
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to
-# deal in the Software without restriction, including without limitation the
-# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-# sell copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-# IN THE SOFTWARE.
-
-use 5.014;
-use strict;
-use warnings;
-
-use lib 't/lib';
-
-use HTTP::Response;
-use JSON;
-use Test::Mock::Duo::Agent;
-use Test::More;
-
-BEGIN {
-    use_ok('Net::Duo::Auth');
-}
-
-# Arguments for the Net::Duo constructor.
-my %args = (key_file => 't/data/integrations/auth.json');
-
-# Create the Net::Duo::Auth object with our testing integration configuration
-# and a mock agent.
-my $mock = Test::Mock::Duo::Agent->new(\%args);
-$args{user_agent} = $mock;
-my $duo = Net::Duo::Auth->new(\%args);
-isa_ok($duo, 'Net::Duo::Auth');
-
-# Create a JSON encoder.
-my $json = JSON->new->utf8(1);
-
-# Set expected data for a successful validation call.
-my $reply = {
-    stat     => 'OK',
-    response => {
-        result     => 'allow',
-        status     => 'allow',
-        status_msg => 'Success. Logging you in...',
-    },
-};
-my $response = HTTP::Response->new;
-$response->code(200);
-$response->message('Success');
-$response->content($json->encode($reply));
-$mock->expect(
-    {
-        method   => 'POST',
-        uri      => '/auth/v2/auth',
-        response => $response,
-        content  => {
-            username => 'user',
-            passcode => 'passcode',
-            factor   => 'passcode',
-        },
-    }
-);
-
-# Make the call and check the response.
-note('Testing token validation');
-is($duo->validate_passcode('user', 'passcode'),
-    'allow', 'Decoded /auth response is correct');
-
-# Out of band validation.
-$reply->{response} = { txid => 'id' };
-$response->content($json->encode($reply));
-$mock->expect(
-    {
-        method   => 'POST',
-        uri      => '/auth/v2/auth',
-        response => $response,
-        content  => {
-            username => 'user',
-            factor   => 'auto',
-            device   => 'auto',
-            async    => 1,
-        },
-    }
-);
-note('Testing out-of-band validation');
-is($duo->validate_out_of_band('user'),
-    'id', 'Decoded /auth response is correct');
-
-# Out of band auth_status.
-$reply->{response} = {
-    result     => 'allow',
-    status     => 'allow',
-    status_msg => 'Success. Logging you in...',
-};
-$response->content($json->encode($reply));
-$mock->expect(
-    {
-        method   => 'GET',
-        uri      => '/auth/v2/auth_status',
-        response => $response,
-        content  => { txid => 'id' },
-    }
-);
-note('Testing auth_status');
-is($duo->auth_status('id'),
-    'allow', 'Decoded /auth_status response is correct');
-
-# Finished.  Tell Test::More that.
-done_testing();
diff --git a/t/data/responses/integration.json b/t/data/responses/integration.json
new file mode 100644
index 0000000..28ef616
--- /dev/null
+++ b/t/data/responses/integration.json
@@ -0,0 +1,11 @@
+{
+    "enroll_policy": "enroll",
+    "greeting": "",
+    "groups_allowed": [],
+    "integration_key": "DIRWIH0ZZPV4G88B37VQ",
+    "name": "Integration for the web server",
+    "notes": "",
+    "secret_key": "QO4ZLqQVRIOZYkHfdPDORfcNf8LeXIbCWwHazY7o",
+    "type": "websdk",
+    "visual_style": "default"
+}
diff --git a/t/data/responses/integrations.json b/t/data/responses/integrations.json
new file mode 100644
index 0000000..41e8f58
--- /dev/null
+++ b/t/data/responses/integrations.json
@@ -0,0 +1,21 @@
+[
+    {
+        "enroll_policy": "enroll",
+        "greeting": "",
+        "groups_allowed": [],
+        "integration_key": "DIRWIH0ZZPV4G88B37VQ",
+        "ip_whitelist": [
+            "192.0.2.8",
+            "198.51.100.0-198.51.100.20",
+            "203.0.113.0/24"
+        ],
+        "ip_whitelist_enroll_policy": "enforce",
+        "name": "Integration for the web server",
+        "notes": "",
+        "secret_key": "QO4ZLqQVRIOZYkHfdPDORfcNf8LeXIbCWwHazY7o",
+        "type": "websdk",
+        "trusted_device_days": 0,
+        "username_normalization_policy": "None",
+        "visual_style": "default"
+    }
+]
diff --git a/t/data/responses/log-admin.json b/t/data/responses/log-admin.json
new file mode 100644
index 0000000..aaa22ce
--- /dev/null
+++ b/t/data/responses/log-admin.json
@@ -0,0 +1,9 @@
+[
+    {
+        "action": "user_update",
+        "description": "{\"notes\": \"OK\", \"realname\": \"Joe Smith\"}",
+        "object": "jsmith",
+        "timestamp": 1346172820,
+        "username": "admin"
+    }
+]
diff --git a/t/data/responses/log-auth.json b/t/data/responses/log-auth.json
new file mode 100644
index 0000000..59126c7
--- /dev/null
+++ b/t/data/responses/log-auth.json
@@ -0,0 +1,10 @@
+[
+    {
+        "factor": "Phone Call",
+        "integration": "adminapi",
+        "ip": "192.168.0.1",
+        "result": "SUCCESS",
+        "timestamp": 1346172697,
+        "username": "jsmith"
+    }
+]
diff --git a/t/data/responses/log-telephony.json b/t/data/responses/log-telephony.json
new file mode 100644
index 0000000..382fb40
--- /dev/null
+++ b/t/data/responses/log-telephony.json
@@ -0,0 +1,9 @@
+[
+    {
+        "context": "authentication",
+        "credits": 1,
+        "phone": "+15035550100",
+        "timestamp": 1346172697,
+        "type": "sms"
+    }
+]
diff --git a/t/lib/Test/RRA/Duo.pm b/t/lib/Test/RRA/Duo.pm
index 7bcbb56..948c42b 100644
--- a/t/lib/Test/RRA/Duo.pm
+++ b/t/lib/Test/RRA/Duo.pm
@@ -53,6 +53,13 @@ my @PHONE_KEYS = qw(
   phone_id number extension name postdelay predelay type platform
   activated sms_passcodes_sent
 );
+my @INTEGRATION_KEYS = qw(
+  adminapi_admins adminapi_info adminapi_integrations adminapi_read_log
+  adminapi_read_resource adminapi_settings adminapi_write_resource
+  enroll_policy greeting integration_key ip_whitelist_enroll_policy name
+  notes secret_key trusted_device_days type username_normalization_policy
+  visual_style
+);
 
 # Declare variables that should be set in BEGIN for robustness.
 our @EXPORT_OK;
@@ -61,7 +68,8 @@ our @EXPORT_OK;
 # circular module loading.
 BEGIN {
     @EXPORT_OK = qw(
-      is_admin_group is_admin_phone is_admin_token is_admin_user
+      is_admin_group is_admin_integration is_admin_phone is_admin_token
+      is_admin_user
     );
 }
 
@@ -111,13 +119,42 @@ sub is_admin_group {
     isa_ok($seen, 'Net::Duo::Admin::Group', $prefix);
 
     # Check the underlying data.
-    $prefix //= q{};
+    $prefix = defined($prefix) ? "$prefix " : q{};
     for my $key (@GROUP_KEYS) {
-        is($seen->$key, $expected->{$key}, "...$prefix $key");
+        is($seen->$key, $expected->{$key}, "...$prefix$key");
     }
     return;
 }
 
+# Given a Net::Duo::Admin::Integration object and the data structure
+# representation of the JSON for that user, check that all the data fields
+# match.  Test results are reported via Test::More.
+#
+# $seen     - The Net::Duo::Admin::Integration object
+# $expected - The data structure representing that group
+# $prefix   - The prefix for the comment on test results
+#
+# Returns: undef
+sub is_admin_integration {
+    my ($seen, $expected, $prefix) = @_;
+
+    # Check object type.
+    isa_ok($seen, 'Net::Duo::Admin::Integration', $prefix);
+
+    # Check the underlying data.
+    $prefix = defined($prefix) ? "$prefix " : q{};
+    for my $key (@INTEGRATION_KEYS) {
+        is($seen->$key, $expected->{$key}, "...$prefix$key");
+    }
+
+    # Check the groups_allowed and ip_whitelist fields, which are arrays.
+    my $want = $expected->{groups_allowed} // [];
+    is_deeply([$seen->groups_allowed], $want, "...${prefix}groups_allowed");
+    $want = $expected->{ip_whitelist} // [];
+    is_deeply([$seen->ip_whitelist], $want, "...${prefix}ip_whitelist");
+    return;
+}
+
 # Given a Net::Duo::Admin::Phone object and the data structure representation
 # of the JSON for that user, check that all the data fields match.  Test
 # results are reported via Test::More.
@@ -134,17 +171,14 @@ sub is_admin_phone {
     isa_ok($seen, 'Net::Duo::Admin::Phone', $prefix);
 
     # Check the underlying simple data.
-    $prefix //= q{};
+    $prefix = defined($prefix) ? "$prefix " : q{};
     for my $key (@PHONE_KEYS) {
-        is($seen->$key, $expected->{$key}, "...$prefix $key");
+        is($seen->$key, $expected->{$key}, "...$prefix$key");
     }
 
     # Check the capabilities, which is an array.
-    is_deeply(
-        [$seen->capabilities],
-        $expected->{capabilities},
-        "...$prefix capabilities",
-    );
+    my $want = $expected->{capabilities} // [];
+    is_deeply([$seen->capabilities], $want, "...${prefix}capabilities");
     return;
 }
 
@@ -164,9 +198,9 @@ sub is_admin_token {
     isa_ok($seen, 'Net::Duo::Admin::Token', $prefix);
 
     # Check the underlying simple data.
-    $prefix //= q{};
+    $prefix = defined($prefix) ? "$prefix " : q{};
     for my $key (@TOKEN_KEYS) {
-        is($seen->$key, $expected->{$key}, "...$prefix $key");
+        is($seen->$key, $expected->{$key}, "...$prefix$key");
     }
     return;
 }
@@ -189,7 +223,7 @@ sub is_admin_user {
     # Check the top-level, simple data.  We can't just use is_deeply on the
     # top-level object because we've converted some of the underlying hashes
     # to other objects, so we walk specific keys and confirm they match.
-    $prefix //= q{};
+    $prefix = defined($prefix) ? "$prefix " : q{};
     for my $key (@USER_KEYS) {
         is($seen->$key, $expected->{$key}, "...$prefix$key");
     }
@@ -262,6 +296,10 @@ data (EXPECTED), and a prefix for the messages for test results (PREFIX).
 
 Check a Net::Duo::Admin::Group object.
 
+=item is_admin_integration(SEEN, EXPECTED, PREFIX)
+
+Check a Net::Duo::Admin::Integration object.
+
 =item is_admin_phone(SEEN, EXPECTED, PREFIX)
 
 Check a Net::Duo::Admin::Phone object.

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-perl/packages/libnet-duo-perl.git



More information about the Pkg-perl-cvs-commits mailing list