[Rt-commit] rt branch, 5.0/core-rt-authentoken, created. rt-5.0.0alpha1-264-g3d6983609b

Craig Kaiser craig at bestpractical.com
Fri May 8 10:22:19 EDT 2020


The branch, 5.0/core-rt-authentoken has been created
        at  3d6983609b6fb65e3b03a8d74aa36f2ea9cdb5f4 (commit)

- Log -----------------------------------------------------------------
commit ba09539cddb5dad5c071339312bddea24b7b83df
Author: Craig <craig at bestpractical.com>
Date:   Mon May 4 17:57:42 2020 -0400

    Core RT::Authen::Token

diff --git a/etc/acl.Pg b/etc/acl.Pg
index 41a44b16c1..dc3ca03f37 100644
--- a/etc/acl.Pg
+++ b/etc/acl.Pg
@@ -68,6 +68,8 @@ sub acl {
         ObjectCustomRoles
         configurations_id_seq
         Configurations
+        authtokens_id_seq
+        AuthTokens
     );
 
     my $db_user = RT->Config->Get('DatabaseUser');
diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index 2b366299cf..5117ad2ef8 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -554,3 +554,18 @@ CREATE TABLE Configurations (
 
 CREATE INDEX Configurations1 ON Configurations (LOWER(Name), Disabled);
 CREATE INDEX Configurations2 ON Configurations (Disabled);
+
+CREATE SEQUENCE AuthTokens_seq;
+CREATE TABLE AuthTokens (
+    id              NUMBER(11,0)    CONSTRAINT AuthTokens_key PRIMARY KEY,
+    Owner           NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Token           VARCHAR2(256),
+    Description     varchar2(255)   DEFAULT '',
+    LastUsed        DATE,
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
diff --git a/etc/schema.Pg b/etc/schema.Pg
index c51be5dff3..5f6c3d85fb 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -796,3 +796,18 @@ CREATE TABLE Configurations (
 CREATE INDEX Configurations1 ON Configurations (LOWER(Name), Disabled);
 CREATE INDEX Configurations2 ON Configurations (Disabled);
 
+CREATE SEQUENCE authtokens_id_seq;
+CREATE TABLE AuthTokens (
+    id                integer                  DEFAULT nextval('authtokens_id_seq'),
+    Owner             integer         NOT NULL DEFAULT 0,
+    Token             varchar(256)    NULL,
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    LastUsed          timestamp                DEFAULT NULL,
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index c51070aa87..bc8b456ecd 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -585,3 +585,16 @@ CREATE TABLE Configurations (
 CREATE INDEX Configurations1 ON Configurations (Name, Disabled);
 CREATE INDEX Configurations2 ON Configurations (Disabled);
 
+CREATE TABLE AuthTokens (
+    id                INTEGER PRIMARY KEY,
+    Owner             int(11)         NOT NULL DEFAULT 0,
+    Token             varchar(256)    collate NOCASE NULL  ,
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    LastUsed          timestamp                DEFAULT NULL,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE INDEX AuthTokensOwner on AuthTokens (Owner);
diff --git a/etc/schema.mysql b/etc/schema.mysql
index ca90073340..69cb029093 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -576,3 +576,17 @@ CREATE TABLE Configurations (
 CREATE INDEX Configurations1 ON Configurations (Name, Disabled);
 CREATE INDEX Configurations2 ON Configurations (Disabled);
 
+CREATE TABLE AuthTokens (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Owner             int(11)         NOT NULL DEFAULT 0,
+    Token             varchar(256)    NULL,
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    LastUsed          datetime                 DEFAULT NULL,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
diff --git a/etc/upgrade/4.5.5/acl.Oracle b/etc/upgrade/4.5.5/acl.Oracle
new file mode 100644
index 0000000000..4d6b3ca776
--- /dev/null
+++ b/etc/upgrade/4.5.5/acl.Oracle
@@ -0,0 +1,2 @@
+sub acl { return () }
+1;
diff --git a/etc/upgrade/4.5.5/acl.Pg b/etc/upgrade/4.5.5/acl.Pg
new file mode 100644
index 0000000000..40621b4dd4
--- /dev/null
+++ b/etc/upgrade/4.5.5/acl.Pg
@@ -0,0 +1,30 @@
+sub acl {
+    my $dbh = shift;
+
+    my @acls;
+    my @tables = qw (
+        authtokens_id_seq
+        AuthTokens
+    );
+
+    my $db_user = RT->Config->Get('DatabaseUser');
+
+    my $sequence_right
+        = ( $dbh->{pg_server_version} >= 80200 )
+        ? "USAGE, SELECT, UPDATE"
+        : "SELECT, UPDATE";
+
+    foreach my $table (@tables) {
+        # Tables are upper-case, sequences are lowercase in @tables
+        if ( $table =~ /^[a-z]/ ) {
+            push @acls, "GRANT $sequence_right ON $table TO \"$db_user\";"
+        }
+        else {
+            push @acls, "GRANT SELECT, INSERT, UPDATE, DELETE ON $table TO \"$db_user\";"
+        }
+    }
+    return (@acls);
+}
+
+1;
+
diff --git a/etc/upgrade/4.5.5/acl.mysql b/etc/upgrade/4.5.5/acl.mysql
new file mode 100644
index 0000000000..4d6b3ca776
--- /dev/null
+++ b/etc/upgrade/4.5.5/acl.mysql
@@ -0,0 +1,2 @@
+sub acl { return () }
+1;
diff --git a/etc/upgrade/4.5.5/schema.Oracle b/etc/upgrade/4.5.5/schema.Oracle
new file mode 100644
index 0000000000..72a22c1461
--- /dev/null
+++ b/etc/upgrade/4.5.5/schema.Oracle
@@ -0,0 +1,15 @@
+CREATE SEQUENCE AuthTokens_seq;
+CREATE TABLE AuthTokens (
+    id              NUMBER(11,0)    CONSTRAINT AuthTokens_key PRIMARY KEY,
+    Owner           NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Token           VARCHAR2(256),
+    Description     varchar2(255)   DEFAULT '',
+    LastUsed        DATE,
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
+
diff --git a/etc/upgrade/4.5.5/schema.Pg b/etc/upgrade/4.5.5/schema.Pg
new file mode 100644
index 0000000000..29a5372a89
--- /dev/null
+++ b/etc/upgrade/4.5.5/schema.Pg
@@ -0,0 +1,15 @@
+CREATE SEQUENCE authtokens_id_seq;
+CREATE TABLE AuthTokens (
+    id                integer                  DEFAULT nextval('authtokens_id_seq'),
+    Owner             integer         NOT NULL DEFAULT 0,
+    Token             varchar(256)    NULL,
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    LastUsed          timestamp                DEFAULT NULL,
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
diff --git a/etc/upgrade/4.5.5/schema.SQLite b/etc/upgrade/4.5.5/schema.SQLite
new file mode 100644
index 0000000000..cccb19b949
--- /dev/null
+++ b/etc/upgrade/4.5.5/schema.SQLite
@@ -0,0 +1,14 @@
+CREATE TABLE AuthTokens (
+    id                INTEGER PRIMARY KEY,
+    Owner             int(11)         NOT NULL DEFAULT 0,
+    Token             varchar(256)    collate NOCASE NULL  ,
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    LastUsed          timestamp                DEFAULT NULL,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE INDEX AuthTokensOwner on AuthTokens (Owner);
+
diff --git a/etc/upgrade/4.5.5/schema.mysql b/etc/upgrade/4.5.5/schema.mysql
new file mode 100644
index 0000000000..ca5334c888
--- /dev/null
+++ b/etc/upgrade/4.5.5/schema.mysql
@@ -0,0 +1,15 @@
+CREATE TABLE AuthTokens (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Owner             int(11)         NOT NULL DEFAULT 0,
+    Token             varchar(256)    NULL,
+    Description       varchar(255)    NOT NULL DEFAULT '',
+    LastUsed          datetime                 DEFAULT NULL,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE INDEX AuthTokensOwner ON AuthTokens (Owner);
+
diff --git a/lib/RT.pm b/lib/RT.pm
index 21f54c46c1..9f9a0e194f 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -506,6 +506,7 @@ sub InitClasses {
     require RT::CustomFieldValues::Canonicalizer;
     require RT::Configuration;
     require RT::Configurations;
+    require RT::Authen::Token;
 
     _BuildTableAttributes();
 
diff --git a/lib/RT/Authen/Token.pm b/lib/RT/Authen/Token.pm
new file mode 100644
index 0000000000..4670f711f6
--- /dev/null
+++ b/lib/RT/Authen/Token.pm
@@ -0,0 +1,128 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::Authen::Token;
+
+use strict;
+use warnings;
+
+use RT::System;
+
+'RT::System'->AddRight(Staff => ManageAuthTokens => 'Manage authentication tokens'); # loc
+
+use RT::Authen::Token::AuthToken;
+use RT::Authen::Token::AuthTokens;
+
+sub UserForAuthString {
+    my $self = shift;
+    my $authstring = shift;
+    my $user = shift;
+
+    my ($user_id, $cleartext_token) = RT::Authen::Token::AuthToken->ParseAuthString($authstring);
+    return unless $user_id;
+
+    my $user_obj = RT::CurrentUser->new;
+    $user_obj->Load($user_id);
+    return if !$user_obj->Id || $user_obj->Disabled;
+
+    if (length $user) {
+        my $check_user = RT::CurrentUser->new;
+        $check_user->Load($user);
+        return unless $check_user->Id && $user_obj->Id == $check_user->Id;
+    }
+
+    my $tokens = RT::Authen::Token::AuthTokens->new(RT->SystemUser);
+    $tokens->LimitOwner(VALUE => $user_id);
+    while (my $token = $tokens->Next) {
+        if ($token->IsToken($cleartext_token)) {
+            $token->UpdateLastUsed;
+            return ($user_obj, $token);
+        }
+    }
+
+    return;
+}
+
+=head1 NAME
+
+RT-Authen-Token - token-based authentication
+
+=head1 DESCRIPTION
+
+This module adds the ability for users to generate and login with
+authentication tokens. Users with the C<ManageAuthTokens> permission
+will see a new "Auth Tokens" menu item under "Logged in as ____" ->
+Settings. On that page they will be able to generate new tokens and
+modify or revoke existing tokens.
+
+Once you have an authentication token, you may use it in place of a
+password to log into RT. (Additionally, L<RT::Extension::REST2> allows
+for using auth tokens with the C<Authorization: token> HTTP header.) One
+common use case is to use an authentication token as an
+application-specific password, so that you may revoke that application's
+access without disturbing other applications. You also need not change
+your password, since the application never received it.
+
+If you have the C<AdminUsers> permission, along with
+C<ManageAuthTokens>, you may generate, modify, and revoke tokens for
+other users as well by visiting Admin -> Users -> Select -> (user) ->
+Auth Tokens.
+
+Authentication tokens are stored securely (hashed and salted) in the
+database just like passwords, and so cannot be recovered after they are
+generated.
+
+=item Update your Apache configuration
+
+If you are running RT under Apache, add the following directive to your RT
+Apache configuration to allow RT to access the Authorization header.
+
+    SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
+=cut
+
+1;
diff --git a/lib/RT/Authen/Token/AuthToken.pm b/lib/RT/Authen/Token/AuthToken.pm
new file mode 100644
index 0000000000..93720aa42e
--- /dev/null
+++ b/lib/RT/Authen/Token/AuthToken.pm
@@ -0,0 +1,371 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+use 5.10.1;
+
+package RT::Authen::Token::AuthToken;
+use base 'RT::Record';
+
+require RT::User;
+require RT::Util;
+use Digest::SHA 'sha512_hex';
+
+=head1 NAME
+
+RT::Authen::Token::AuthToken - Represents an authentication token for a user
+
+=cut
+
+=head1 METHODS
+
+=head2 Create PARAMHASH
+
+Create takes a hash of values and creates a row in the database.  Available
+keys are:
+
+=over 4
+
+=item Owner
+
+The user ID for whom this token will authenticate. If it's not the AuthToken
+object's CurrentUser, then the AdminUsers permission is required.
+
+=item Description
+
+A human-readable description of what this token will be used for.
+
+=back
+
+Returns a tuple of (status, msg) on failure and (id, msg, authstring) on
+success. Note that this is the only time the authstring will be directly
+readable (as it is stored in the database hashed like a password, so use
+this opportunity to capture it.
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Owner       => undef,
+        Description => '',
+        @_,
+    );
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserHasRight('ManageAuthTokens');
+
+    return (0, $self->loc("Owner required"))
+        unless $args{Owner};
+
+    return (0, $self->loc("Permission Denied"))
+        unless $args{Owner} == $self->CurrentUser->Id
+            || $self->CurrentUserHasRight('AdminUsers');
+
+    my $token = $self->_GenerateToken;
+
+    my ( $id, $msg ) = $self->SUPER::Create(
+        Token => $self->_CryptToken($token),
+        map { $_ => $args{$_} } grep {exists $args{$_}}
+            qw(Owner Description),
+    );
+    unless ($id) {
+        return (0, $self->loc("Authentication token create failed: [_1]", $msg));
+    }
+
+    my $authstring = $self->_BuildAuthString($self->Owner, $token);
+
+    return ($id, $self->loc('Authentication token created'), $authstring);
+}
+
+=head2 CurrentUserCanSee
+
+Returns true if the current user can see the AuthToken
+
+=cut
+
+sub CurrentUserCanSee {
+    my $self = shift;
+
+    return 0 unless $self->CurrentUserHasRight('ManageAuthTokens');
+
+    return 0 unless $self->__Value('Owner') == $self->CurrentUser->Id
+                 ||  $self->CurrentUserHasRight('AdminUsers');
+
+    return 1;
+}
+
+=head2 SetOwner
+
+Not permitted
+
+=cut
+
+sub SetOwner {
+    my $self = shift;
+    return (0, $self->loc("Permission Denied"));
+}
+
+=head2 SetToken
+
+Not permitted
+
+=cut
+
+sub SetToken {
+    my $self = shift;
+    return (0, $self->loc("Permission Denied"));
+}
+
+=head2 Delete
+
+Checks ACL
+
+=cut
+
+sub Delete {
+    my $self = shift;
+    return (0, $self->loc("Permission Denied")) unless $self->CurrentUserCanSee;
+    my ($ok, $msg) = $self->SUPER::Delete(@_);
+    return ($ok, $self->loc("Authentication token revoked.")) if $ok;
+    return ($ok, $msg);
+}
+
+=head2 UpdateLastUsed
+
+Sets the "last used" time, without touching "last updated"
+
+=cut
+
+sub UpdateLastUsed {
+    my $self = shift;
+
+    my $now = RT::Date->new( $self->CurrentUser );
+    $now->SetToNow;
+
+    return $self->__Set(
+        Field => 'LastUsed',
+        Value => $now->ISO,
+    );
+}
+
+=head2 ParseAuthString AUTHSTRING
+
+Class method that takes as input an authstring and provides a tuple
+of (user id, token) on success, or the empty list on failure.
+
+=cut
+
+sub ParseAuthString {
+    my $class = shift;
+    my $input = shift;
+
+    my ($version) = $input =~ s/^([0-9]+)-//
+        or return;
+
+    if ($version == 1) {
+        my ($user_id, $token) = $input =~ /^([0-9]+)-([0-9a-f]{32})$/i
+            or return;
+        return ($user_id, $token);
+    }
+
+    return;
+}
+
+=head2 IsToken
+
+Analogous to L<RT::User/IsPassword>, without all of the legacy password
+forms.
+
+=cut
+
+sub IsToken {
+    my $self = shift;
+    my $value = shift;
+
+    my $stored = $self->__Value('Token');
+
+    # If it's a new-style (>= RT 4.0) password, it starts with a '!'
+    my (undef, $method, @rest) = split /!/, $stored;
+    if ($method eq "bcrypt") {
+        if (RT::Util->can('constant_time_eq')) {
+            return 0 unless RT::Util::constant_time_eq(
+                $self->_CryptToken_bcrypt($value, @rest),
+                $stored,
+            );
+        } else {
+            return 0 unless $self->_CryptToken_bcrypt($value, @rest) eq $stored;
+        }
+        # Upgrade to a larger number of rounds if necessary
+        return 1 unless $rest[0] < RT->Config->Get('BcryptCost');
+    }
+    else {
+        $RT::Logger->warn("Unknown hash method $method");
+        return 0;
+    }
+
+    # We got here by validating successfully, but with a legacy
+    # password form.  Update to the most recent form.
+    $self->_Set(Field => 'Token', Value => $self->_CryptToken($value));
+    return 1;
+}
+
+=head2 LastUsedObj
+
+L</LastUsed> as an L<RT::Date> object.
+
+=cut
+
+sub LastUsedObj {
+    my $self = shift;
+    my $date = RT::Date->new($self->CurrentUser);
+    $date->Set(Format => 'sql', Value => $self->LastUsed);
+    return $date;
+}
+
+=head1 PRIVATE METHODS
+
+Documented for internal use only, do not call these from outside
+RT::Authen::Token::AuthToken itself.
+
+=head2 _Set
+
+Checks if the current user can I<ManageAuthTokens> before calling
+C<SUPER::_Set>.
+
+=cut
+
+sub _Set {
+    my $self = shift;
+    my %args = (
+        Field => undef,
+        Value => undef,
+        @_
+    );
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserCanSee;
+
+    return $self->SUPER::_Set(@_);
+}
+
+=head2 _Value
+
+Checks L</CurrentUserCanSee> before calling C<SUPER::_Value>.
+
+=cut
+
+sub _Value {
+    my $self = shift;
+    return unless $self->CurrentUserCanSee;
+    return $self->SUPER::_Value(@_);
+}
+
+=head2 _GenerateToken
+
+Generates an unpredictable auth token
+
+=cut
+
+sub _GenerateToken {
+    my $class = shift;
+    require Time::HiRes;
+
+    my $input = join '',
+                    Time::HiRes::time(), # subsecond-precision time
+                    {},                  # unpredictable memory address
+                    rand();              # RNG
+
+    my $digest = sha512_hex($input);
+
+    return substr($digest, 0, 32);
+}
+
+=head2 _BuildAuthString
+
+Takes a user id and token and provides an authstring for use in place of
+a (username, password) combo.
+
+=cut
+
+sub _BuildAuthString {
+    my $self    = shift;
+    my $version = 1;
+    my $userid  = shift;
+    my $token   = shift;
+
+    return $version . '-' . $userid . '-' . $token;
+}
+
+sub _CryptToken_bcrypt {
+    my $self = shift;
+    return $self->CurrentUser->UserObj->_GeneratePassword_bcrypt(@_);
+}
+
+sub _CryptToken {
+    my $self = shift;
+    return $self->_CryptToken_bcrypt(@_);
+}
+
+sub Table { "AuthTokens" }
+
+sub _CoreAccessible {
+    {
+        id            => { read => 1, type => 'int(11)',        default => '' },
+        Owner         => { read => 1, type => 'int(11)',        default => '0' },
+        Token         => { read => 1, sql_type => 12, length => 256, is_blob => 0, is_numeric => 0, type => 'varchar(256)', default => ''},
+        Description   => { read => 1, type => 'varchar(255)',   default => '',  write => 1 },
+        LastUsed      => { read => 1, type => 'datetime',       default => '',  write => 1 },
+        Creator       => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        Created       => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+        LastUpdatedBy => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        LastUpdated   => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+    }
+}
+
+1;
diff --git a/lib/RT/Authen/Token/AuthTokens.pm b/lib/RT/Authen/Token/AuthTokens.pm
new file mode 100644
index 0000000000..4136aebf11
--- /dev/null
+++ b/lib/RT/Authen/Token/AuthTokens.pm
@@ -0,0 +1,98 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+
+package RT::Authen::Token::AuthTokens;
+use base 'RT::SearchBuilder';
+
+=head1 NAME
+
+RT::Authen::Token::AuthTokens - a collection of L<RT::Authen::Token::AuthToken> objects
+
+=cut
+
+=head2 LimitOwner
+
+Limit Owner
+
+=cut
+
+sub LimitOwner {
+    my $self = shift;
+    my %args = (
+        FIELD    => 'Owner',
+        OPERATOR => '=',
+        @_
+    );
+
+    $self->SUPER::Limit(%args);
+}
+
+sub NewItem {
+    my $self = shift;
+    return RT::Authen::Token::AuthToken->new( $self->CurrentUser );
+}
+
+=head2 _Init
+
+Sets default ordering by id ascending.
+
+=cut
+
+sub _Init {
+    my $self = shift;
+
+    $self->OrderBy( FIELD => 'id', ORDER => 'ASC' );
+    return $self->SUPER::_Init( @_ );
+}
+
+sub Table { "AuthTokens" }
+
+1;
+
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 322f70f269..a6fa51e5ed 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -144,6 +144,7 @@ sub JSFiles {
         Chart.min.js
         chartjs-plugin-colorschemes.min.js
         jquery.jgrowl.min.js
+        rt-authen-token.js
         }, RT->Config->Get('JSFiles');
 }
 
@@ -349,6 +350,47 @@ sub HandleRequest {
         # Authenticate if the user is trying to login via user/pass query args
         my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
 
+        unless ($authed) {
+            my $get_env = sub {
+                my $key = shift;
+                if (RT::Interface::Web->can('RequestENV')) {
+                    return RT::Interface::Web::RequestENV($key)
+                }
+                return $ENV{$key};
+            };
+
+            my ($pass, $user) = ('', '');
+            if (($get_env->('HTTP_AUTHORIZATION')||'') =~ /^token (.*)$/i) {
+                $pass ||= $1;
+            }
+            unless ( defined $pass ) {
+                my ($user_obj, $token) = RT::Authen::Token->UserForAuthString($pass, $user);
+                if ( $user_obj ) {
+                    # log in
+                    my $remote_addr = $get_env->('REMOTE_ADDR');
+                    $RT::Logger->info("Successful login for @{[$user_obj->Name]} from $remote_addr using authentication token #@{[$token->Id]} (\"@{[$token->Description]}\")");
+
+                    # It's important to nab the next page from the session before we blow
+                    # the session away
+                    my $next = RT::Interface::Web::RemoveNextPage($ARGS->{'next'});
+                    $next = $next->{'url'} if ref $next;
+
+                    RT::Interface::Web::InstantiateNewSession();
+                    $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
+
+                    # Really the only time we don't want to redirect here is if we were
+                    # passed user and pass as query params in the URL.
+                    if ($next) {
+                        RT::Interface::Web::Redirect($next);
+                    }
+                    elsif ($ARGS->{'next'}) {
+                        # Invalid hash, but still wants to go somewhere, take them to /
+                        RT::Interface::Web::Redirect(RT->Config->Get('WebURL'));
+                    }
+                }
+            }
+        }
+
         unless ($authed) {
             my $m = $HTML::Mason::Commands::m;
 
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index c8e58daeed..42740c32cc 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -304,6 +304,10 @@ sub BuildMainNav {
         );
         $settings->child( queue_list    => title => loc('Queue list'),   path => '/Prefs/QueueList.html' );
 
+        if ( $current_user->HasRight( Right => 'ManageAuthTokens', Object => RT->System ) ) {
+            $settings->child('about_me')->add_after(auth_tokens => title => loc('Auth Tokens'), path => '/Prefs/AuthTokens.html');
+        }
+
         my $search_menu = $settings->child( 'saved-searches' => title => loc('Saved Searches') );
         my $searches = [ $HTML::Mason::Commands::m->comp( "/Search/Elements/SearchesForObject",
                           Object => RT::System->new( $current_user )) ];
@@ -1531,6 +1535,20 @@ sub _BuildAdminMenu {
             $page->child( create => title => loc('Create'), path => "/Admin/Articles/Classes/Modify.html?Create=1" );
         }
     }
+
+    my $request_path_token = $request_path =~ s!/{2,}!/!g;
+    if ( $request_path_token =~ m{^(/Admin/Users|/User/(Summary|History)\.html)} and $admin->child("users") ) {
+        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
+            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
+            my $obj = RT::User->new( $HTML::Mason::Commands::session{'CurrentUser'} );
+            $obj->Load($id);
+
+            if ( $obj and $obj->id ) {
+                my $tabs = PageMenu();
+                $tabs->child(auth_tokens => title => loc('Auth Tokens'), path => '/Admin/Users/AuthTokens.html?id=' . $id);
+            }
+        }
+    }
 }
 
 sub BuildSelfServiceNav {
diff --git a/share/html/Admin/Users/AuthTokens.html b/share/html/Admin/Users/AuthTokens.html
new file mode 100644
index 0000000000..34de303304
--- /dev/null
+++ b/share/html/Admin/Users/AuthTokens.html
@@ -0,0 +1,78 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("[_1]'s authentication tokens",$UserObj->Name)  &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<div class="form-row">
+  <div class="auth-tokens col-12">
+    <p><&|/l&>Authentication tokens allow other applications to use your user
+        account without having to share your password, while allowing you to
+       revoke access on an application-specific basis. Changing your password
+        <em>does not</em> invalidate your auth tokens; you must revoke them here.</&>
+    </p>
+  </div>
+</div>
+
+<& /Elements/AuthToken/CreateButton, %ARGS, Owner => $UserObj->Id &>
+<& /Elements/AuthToken/List, %ARGS, Owner => $UserObj->Id &>
+</div>
+
+<%ARGS>
+$id => undef
+</%ARGS>
+<%INIT>
+my @results;
+
+my $UserObj = RT::User->new( $session{'CurrentUser'} );
+$UserObj->Load( $id );
+unless ( $UserObj->id ) {
+    Abort( loc("Couldn't load user #[_1]", $id) );
+}
+$id = $ARGS{'id'} = $UserObj->id;
+</%INIT>
diff --git a/share/html/Elements/AuthToken/CreateButton b/share/html/Elements/AuthToken/CreateButton
new file mode 100644
index 0000000000..58c386e302
--- /dev/null
+++ b/share/html/Elements/AuthToken/CreateButton
@@ -0,0 +1,72 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Owner
+$ShowCreateForm => 0
+$CreateToken => 0
+</%ARGS>
+<%INIT>
+</%INIT>
+% if ($CreateToken) {
+  <&| /Widgets/TitleBox, title => loc("Create Auth Token") &>
+    <& /Elements/AuthToken/CreateResults, %ARGS &>
+  </&>
+% } elsif ($ShowCreateForm) {
+  <&| /Widgets/TitleBox, title => loc("Create Auth Token") &>
+    <& /Elements/AuthToken/CreateForm, Owner => $Owner &>
+  </&>
+% } else {
+<div class="authtoken-form-container">
+  <& /Elements/AuthToken/CreateForm, Owner => $Owner &>
+</div>
+<form method="GET">
+  <input type="hidden" name="ShowCreateForm" value="1">
+  <input type="hidden" name="id" value="<% $Owner %>">
+  <button type="submit" class="authtoken-create">Create Auth Token</button>
+</form>
+% }
diff --git a/share/html/Elements/AuthToken/CreateForm b/share/html/Elements/AuthToken/CreateForm
new file mode 100644
index 0000000000..8a73fb9b5d
--- /dev/null
+++ b/share/html/Elements/AuthToken/CreateForm
@@ -0,0 +1,103 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Owner
+$Description => ''
+</%ARGS>
+<%INIT>
+# Don't require password for systems with some form of federated auth
+my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
+</%INIT>
+
+<div class="modal" id="create-auth-token">
+  <div class="modal-dialog modal-dialog-centered" role="document">
+    <div class="modal-content">
+        <div class="modal-header">
+        <h5 class="modal-title"><&|/l&>Create auth token</&></h5>
+        <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">×</span>
+        </a>
+        </div>
+        <div class="modal-body">
+          <form class="authtoken-form" method="post" data-ajax-url="<% RT->Config->Get('WebPath') %>/Helpers/AuthToken/Create">
+            <div class="form-row">
+              <input type="hidden" name="Owner" value="<% $Owner %>">
+% if ( $res{'CanSet'} ){
+              <div class="label col-3">
+                <&|/l, $session{'CurrentUser'}->Name()&>[_1]'s current password</&>:
+              </div>
+              <div class="value col-9">
+                <input class="form-control" type="password" name="Password" size="16" autocomplete="off" /></td>
+              </div>
+% }
+              <div class="col-3 label">
+                <&|/l&>Description</&>:<br><em><&|/l&>What's this token for?</&></em>
+              </div>
+              <div class="value col-9">
+                <input class="form-control" type="text" name="Description" value="<% $Description %>" size="16" />
+              </div>
+            </div>
+
+            <div class="form-row">
+              <div class="col-12">
+                <& /Elements/Submit, Label => loc("Create"), Name => 'CreateToken' &>
+              </div>
+            </div>
+
+            <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+          </form>
+        </div>
+    </div>
+  </div>
+</div>
+
+<div class="form-row">
+  <div class="col-12">
+    <a class="button btn btn-primary" href="#create-auth-token" data-toggle="modal" rel="modal:open" name="create_auth_token"><&|/l&>Create Auth Token</&></a>
+  </div>
+</div>
diff --git a/share/html/Elements/AuthToken/CreateResults b/share/html/Elements/AuthToken/CreateResults
new file mode 100644
index 0000000000..58bb9da202
--- /dev/null
+++ b/share/html/Elements/AuthToken/CreateResults
@@ -0,0 +1,96 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Owner => undef
+$Password => ''
+$Description => ''
+</%ARGS>
+<%INIT>
+my $token = RT::AuthToken->new($session{CurrentUser});
+# Don't require password for systems with some form of federated auth
+my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
+my ($error, $authstring);
+
+if (!$Owner) {
+    $error = loc("Owner required. Please refresh the page and try again.");
+}
+elsif (!length($Description)) {
+    $error = loc("Description cannot be blank.");
+}
+elsif ($res{'CanSet'} && !length($Password)) {
+    $error = loc("Please enter your current password.");
+}
+elsif ($res{'CanSet'} && !$session{CurrentUser}->IsPassword($Password) ) {
+    $error = loc("Please enter your current password correctly.");
+}
+else {
+    ((my $ok), (my $msg), $authstring) = $token->Create(
+        Owner       => $Owner,
+        Description => $Description,
+    );
+}
+</%INIT>
+<div class="form-row">
+% if ($error) {
+  <& /Elements/AuthToken/CreateForm, Owner => $Owner, Error => $error, Description => $Description &>
+% } else {
+    <div class="authtoken-success">
+      <div class="col-12">
+        <p><&|/l, $Description&>This is your new authentication token. Treat
+            it carefully like a password. Please save it now because you cannot
+            access it again.
+        </&></p>
+      </div>
+      <div class="col-12 text-center">
+        <span class="authstring"><% $authstring %></span>
+      </div>
+    </div>
+  </div>
+</div>
+% }
+
diff --git a/share/html/Elements/AuthToken/List b/share/html/Elements/AuthToken/List
new file mode 100644
index 0000000000..c6f729c295
--- /dev/null
+++ b/share/html/Elements/AuthToken/List
@@ -0,0 +1,138 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Owner
+</%ARGS>
+<%INIT>
+my $tokens = RT::Authen::Token::AuthTokens->new($session{CurrentUser});
+$tokens->LimitOwner(VALUE => $Owner);
+</%INIT>
+<div class="authtoken-list" data-owner="<% $Owner %>">
+  <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+% if ($tokens->Count == 0) {
+  <em><&|/l&>No authentication tokens.</&></em>
+% } else {
+  <ul class="list-group">
+% while (my $token = $tokens->Next) {
+    <div class="form-row">
+      <div class="col-6">
+        <li class="list-group" id="auth-token-<% $token->Id %>">
+
+% my $used = $token->LastUsedObj;
+% my $last_updated = '';
+% if ($used->IsSet) {
+% $last_updated = loc( $used->AgeAsString, "used [_1]" );
+% } else {
+% $last_updated = loc( "never used" );
+% }
+          <&| /Widgets/TitleBox,
+            title          => $token->Description,
+            title_href     => "#auth-token-".$token->Id,
+            titleright_raw => $last_updated,
+            rolledup       => 0
+          &>
+% if ($ARGS{ShowModifyForm} && $ARGS{Token} == $token->Id) {
+%   if ($ARGS{Update} || $ARGS{Revoke}) {
+            <& /Elements/AuthToken/ModifyResults, %ARGS, Token => $token->Id, Owner => $Owner &>
+% }
+% }
+            <form class="authtoken-form" method="post" data-ajax-url="<% RT->Config->Get('WebPath') %>/Helpers/AuthToken/Modify" action="<% RT->Config->Get('WebPath') %><% $r->uri %>">
+              <div class="form-row">
+% if ($ARGS{id}) {
+                <input type="hidden" name="id" value="<% $ARGS{id} %>">
+% }
+                <input type="hidden" name="Token" value="<% $token->id %>">
+                 <input type="hidden" name="Owner" value="<% $Owner %>">
+
+                <div class="col-4">
+                  <label class="label"><&|/l&>Description</&>:
+                    <span class="far fa-question-circle icon-helper" data-toggle="tooltip" data-placement="top" data-original-title="<&|/l&>What's this token for?</&>"></span>
+                  </label>
+                </div>
+                <div class="col-8">
+                  <span class="value"><input class="form-control" type="text" name="Description" value="<% $ARGS{Description} // $token->Description %>" size="16" /></span>
+                </div>
+
+                <div class="col-4">
+                  <label class="label"><&|/l&>Last Used</&>:</label>
+                </div>
+                <div class="col-8">
+% my $used = $token->LastUsedObj;
+                  <span class="value">
+% if ($used->IsSet) {
+                  <% $used->AgeAsString %>
+% } else {
+                  <&|/l&>never</&>
+% }
+                  </span>
+                </div>
+
+                <div class="col-4">
+                  <label class="label"><&|/l&>Created</&>:</label>
+                </div>
+                <div class="col-8">
+                  <span class="value"><% $token->CreatedObj->AgeAsString %></span>
+                </div>
+
+                <div class="col-6">
+                  <input class="button btn btn-primary" type="submit" name="Update" value="<&|/l&>Save</&>"></input>
+                </div>
+                <div class="col-6">
+                  <input class="button btn btn-primary" type="submit" name="Revoke" value="<&|/l&>Revoke</&>"></input>
+                </div>
+                <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+            </form>
+          </div>
+          </&>
+        </li>
+      </div>
+    </div>
+% }
+  </ul>
+% }
+</div>
diff --git a/share/html/Elements/AuthToken/ModifyForm b/share/html/Elements/AuthToken/ModifyForm
new file mode 100644
index 0000000000..c57dbc10f1
--- /dev/null
+++ b/share/html/Elements/AuthToken/ModifyForm
@@ -0,0 +1,123 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Token => undef
+$TokenObj => undef
+$Error => ''
+</%ARGS>
+<%INIT>
+if (!$TokenObj) {
+    $TokenObj = RT::Authen::Token::AuthToken->new($session{CurrentUser});
+    $TokenObj->Load($Token);
+}
+Abort("Unable to load authentication token") if !$TokenObj->Id;
+Abort("Permission Denied") if !$TokenObj->CurrentUserCanSee;
+</%INIT>
+
+<div class="modal" id="edit-auth-token">
+  <div class="modal-dialog modal-dialog-centered" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h5 class="modal-title"><&|/l&>Edit auth token</&></h5>
+        <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+          <span aria-hidden="true">×</span>
+        </a>
+      </div>
+      <div class="modal-body">
+        <form class="authtoken-form" method="post" data-ajax-url="<% RT->Config->Get('WebPath') %>/Helpers/AuthToken/Modify" action="<% RT->Config->Get('WebPath') %><% $r->uri %>">
+          <div class="form-row">
+% if ($Error) {
+            <div class="col-12">
+              <p class="error"><% $Error %></p>
+            </div>
+% }
+% if ($ARGS{id}) {
+            <input type="hidden" name="id" value="<% $ARGS{id} %>">
+% }
+            <input type="hidden" name="Token" value="<% $TokenObj->id %>">
+
+            <div class="form-row">
+              <div class="col-4 label">
+                <&|/l&>Description</&>:<br><em><&|/l&>What's this token for?</&></em>
+              </div>
+              <div class="col-8 value">
+                <input class="form-control" type="text" name="Description" value="<% $ARGS{Description} // $TokenObj->Description %>" size="16" />
+              </div>
+
+              <div class="col-4 label">
+                <&|/l&>Last Used</&>:
+              </div>
+              <div class="col-8 value">
+% my $used = $TokenObj->LastUsedObj;
+% if ($used->IsSet) {
+                <% $used->AgeAsString %>
+% } else {
+                <&|/l&>never</&>
+% }
+              </div>
+
+              <div class="col-4 label">
+                <&|/l&>Created</&>:
+              </div>
+              <div class="col-8 value">
+                <% $TokenObj->CreatedObj->AgeAsString %>
+              </div>
+            </div>
+
+            <div class="buttons">
+              <input type="submit" name="Update" value="<&|/l&>Save</&>"></input>
+              <input type="submit" name="Revoke" value="<&|/l&>Revoke</&>"></input>
+            </div>
+
+            <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/share/html/Elements/AuthToken/ModifyResults b/share/html/Elements/AuthToken/ModifyResults
new file mode 100644
index 0000000000..0bb6ac7a72
--- /dev/null
+++ b/share/html/Elements/AuthToken/ModifyResults
@@ -0,0 +1,83 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$Token
+$Description => ''
+$Update => 0
+$Revoke => 0
+</%ARGS>
+<%INIT>
+my $TokenObj = RT::Authen::Token::AuthToken->new($session{CurrentUser});
+$TokenObj->Load($Token);
+my ($error, $ok, $msg);
+
+if ($Update) {
+    if (!length($Description)) {
+        $error = loc("Description cannot be blank.");
+    }
+
+    if ($Description ne $TokenObj->Description) {
+        ($ok, $msg) = $TokenObj->SetDescription($Description);
+        $error = $msg if !$ok;
+    }
+}
+elsif ($Revoke) {
+    ($ok, $msg) = $TokenObj->Delete;
+}
+</%INIT>
+<div id="messages" class="auth-token-messages col-6">
+% if ( $error ) {
+  <div class="alert alert-danger" role="alert">
+    <% $error %>
+  </div>
+% } if ( $msg ) {
+    <div class="alert alert-success" role="alert">
+      <% $msg %>
+    </div>
+% }
+</div>
diff --git a/share/html/Helpers/AuthToken/Create b/share/html/Helpers/AuthToken/Create
new file mode 100644
index 0000000000..1ff6d3ac0d
--- /dev/null
+++ b/share/html/Helpers/AuthToken/Create
@@ -0,0 +1,49 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/AuthToken/CreateResults, %ARGS &>
+% $m->abort;
diff --git a/share/html/Helpers/AuthToken/List b/share/html/Helpers/AuthToken/List
new file mode 100644
index 0000000000..041fdf13c8
--- /dev/null
+++ b/share/html/Helpers/AuthToken/List
@@ -0,0 +1,49 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/AuthToken/List, Owner => $ARGS{owner} &>
+% $m->abort;
diff --git a/share/html/Helpers/AuthToken/Modify b/share/html/Helpers/AuthToken/Modify
new file mode 100644
index 0000000000..73a88f2a9d
--- /dev/null
+++ b/share/html/Helpers/AuthToken/Modify
@@ -0,0 +1,49 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/AuthToken/ModifyResults, %ARGS &>
+% $m->abort;
diff --git a/share/html/Prefs/AuthTokens.html b/share/html/Prefs/AuthTokens.html
new file mode 100644
index 0000000000..9d9e8b4d47
--- /dev/null
+++ b/share/html/Prefs/AuthTokens.html
@@ -0,0 +1,76 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/Header, Title => loc('My authentication tokens') &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<div class="form-row">
+  <div class="col-12">
+    <p><&|/l&>Authentication tokens allow other applications to use your user
+        account without having to share your password, while allowing you to
+       revoke access on an application-specific basis. Changing your password
+        <em>does not</em> invalidate your auth tokens; you must revoke them here.
+    </&></p>
+  </div>
+
+  <div class="col-12">
+    <& /Elements/AuthToken/CreateForm, %ARGS, Owner => $UserObj->Id &>
+  </div>
+
+  <div id="auth-token-messages" class="auth-token-messages col-12">
+  </div>
+
+  <div class="col-12">
+    <& /Elements/AuthToken/List, %ARGS, Owner => $UserObj->Id &>
+  </div>
+</div>
+
+<%INIT>
+my @results;
+my $UserObj = $session{'CurrentUser'}->UserObj;
+</%INIT>
diff --git a/share/static/css/elevator-light/main.css b/share/static/css/elevator-light/main.css
index dec0b5a144..fb32f7792d 100644
--- a/share/static/css/elevator-light/main.css
+++ b/share/static/css/elevator-light/main.css
@@ -47,3 +47,4 @@
 @import "jquery.jgrowl.min.css";
 @import "inline-edit.css";
 @import "lifecycleui.css";
+ at import "rt-authen-token.css";
diff --git a/share/static/css/elevator-light/rt-authen-token.css b/share/static/css/elevator-light/rt-authen-token.css
new file mode 100644
index 0000000000..80f8ccadb1
--- /dev/null
+++ b/share/static/css/elevator-light/rt-authen-token.css
@@ -0,0 +1,73 @@
+.authtoken-form-container {
+    display: none;
+}
+
+.authtoken-form .loading {
+    float: right;
+    display: none;
+}
+
+.authtoken-form.submitting .buttons {
+    display: none;
+}
+
+.authtoken-form.submitting .loading {
+    display: inline;
+}
+
+.authtoken-form .error {
+    color: red;
+}
+
+.authstring {
+    font-size: 1.2em;
+    font-family: monospace;
+    padding: .3em;
+    border: 1px dashed black;
+    background-color: #f9f9f9;
+}
+
+.authtoken-success {
+    margin-bottom: 15px;
+}
+
+.authtoken-list ul {
+    list-style-type: none;
+    padding-left: 0;
+}
+
+.authtoken-list ul li + li {
+    margin-top: 1em;
+}
+
+.authtoken-list .description {
+    font-weight: bold;
+}
+
+.authtoken-list .lastused {
+    font-style: italic;
+    color: #666;
+}
+
+.authtoken-list .loading {
+    display: none;
+}
+
+.authtoken-list.refreshing {
+    opacity: 0.3;
+}
+.authtoken-list.refreshing .loading {
+    display: inline;
+}
+
+.authtoken-form input[name=Update] {
+    float: right;
+}
+
+.authtoken-form input[name=Revoke] {
+    float: left;
+}
+
+.auth-token-messages {
+    margin: 1rem 0rem 0rem 0px;
+}
diff --git a/share/static/images/loading.gif b/share/static/images/loading.gif
new file mode 100644
index 0000000000..3288d1035d
Binary files /dev/null and b/share/static/images/loading.gif differ
diff --git a/share/static/js/rt-authen-token.js b/share/static/js/rt-authen-token.js
new file mode 100644
index 0000000000..e989851335
--- /dev/null
+++ b/share/static/js/rt-authen-token.js
@@ -0,0 +1,67 @@
+jQuery(function() {
+    var refreshTokenList = function () {
+        var list = jQuery('.authtoken-list');
+        jQuery.post(
+            RT.Config.WebHomePath + "/Helpers/AuthToken/List",
+            list.data(),
+            function (data) {
+                list.replaceWith(data);
+            }
+        );
+    };
+
+    var submitForm = function (form, extraParams) {
+        var payload = form.serializeArray();
+        var name = extraParams[0].name;
+
+        if (extraParams) {
+            Array.prototype.push.apply(payload, extraParams);
+        }
+
+        form.addClass('submitting');
+        form.find('input').attr('disabled', true);
+
+        var renderResult = function(name, html) {
+            if ( name === 'CreateToken' ) {
+                var form = jQuery('.modal .authtoken-form');
+                if (form.length) {
+                    form.replaceWith(html);
+                }
+                else {
+                    jQuery('#body').append(html);
+                }
+            }
+            else {
+                jQuery('#auth-token-messages').replaceWith(html);
+            }
+            refreshTokenList();
+        };
+
+        jQuery.ajax({
+            method: 'POST',
+            url: form.data('ajax-url'),
+            data: payload,
+            timeout: 30000, /* 30 seconds */
+            success: function (data, status) {
+                renderResult(name, data);
+            },
+            error: function (xhr, status, error) {
+                renderResult("<p>An error has occurred. Please refresh the page and try again.<p>");
+            }
+        });
+    };
+
+    jQuery('body').on('click', '.authtoken-form button, .authtoken-form input[type=submit]', function (e) {
+        e.preventDefault();
+        var button = jQuery(this);
+
+        var params = [{ name: button.attr('name'), value: button.attr('value') }];
+        submitForm(button.closest('form'), params);
+    });
+
+    jQuery('body').on('submit', '.authtoken-form', function (e) {
+        e.preventDefault();
+        submitForm(jQuery(this));
+    });
+});
+

commit 34c4eb92e7fb44368effe8d9fc0c72e2d6526304
Author: Craig <craig at bestpractical.com>
Date:   Thu May 7 10:43:41 2020 -0400

    Migrate Authen-Token code to work like other RT pages

diff --git a/lib/RT/Authen/Token.pm b/lib/RT/Authen/Token.pm
index 4670f711f6..cf9c824d89 100644
--- a/lib/RT/Authen/Token.pm
+++ b/lib/RT/Authen/Token.pm
@@ -94,14 +94,13 @@ RT-Authen-Token - token-based authentication
 
 =head1 DESCRIPTION
 
-This module adds the ability for users to generate and login with
-authentication tokens. Users with the C<ManageAuthTokens> permission
-will see a new "Auth Tokens" menu item under "Logged in as ____" ->
-Settings. On that page they will be able to generate new tokens and
-modify or revoke existing tokens.
+Allow for users to generate and login with authentication tokens.
+Users with the C<ManageAuthTokens> permission will see a new "Auth Tokens"
+menu item under "Logged in as ____" -> Settings. On that page they will
+be able to generate new tokens and modify or revoke existing tokens.
 
 Once you have an authentication token, you may use it in place of a
-password to log into RT. (Additionally, L<RT::Extension::REST2> allows
+password to log into RT. (Additionally, L<REST2> allows
 for using auth tokens with the C<Authorization: token> HTTP header.) One
 common use case is to use an authentication token as an
 application-specific password, so that you may revoke that application's
@@ -123,6 +122,7 @@ If you are running RT under Apache, add the following directive to your RT
 Apache configuration to allow RT to access the Authorization header.
 
     SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
+
 =cut
 
 1;
diff --git a/lib/RT/Authen/Token/AuthToken.pm b/lib/RT/Authen/Token/AuthToken.pm
index 93720aa42e..111f5d132c 100644
--- a/lib/RT/Authen/Token/AuthToken.pm
+++ b/lib/RT/Authen/Token/AuthToken.pm
@@ -45,6 +45,7 @@
 # those contributions and any derivatives thereof.
 #
 # END BPS TAGGED BLOCK }}}
+
 use strict;
 use warnings;
 use 5.10.1;
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index a6fa51e5ed..03ec19c6c9 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -349,47 +349,7 @@ sub HandleRequest {
 
         # Authenticate if the user is trying to login via user/pass query args
         my ($authed, $msg) = AttemptPasswordAuthentication($ARGS);
-
-        unless ($authed) {
-            my $get_env = sub {
-                my $key = shift;
-                if (RT::Interface::Web->can('RequestENV')) {
-                    return RT::Interface::Web::RequestENV($key)
-                }
-                return $ENV{$key};
-            };
-
-            my ($pass, $user) = ('', '');
-            if (($get_env->('HTTP_AUTHORIZATION')||'') =~ /^token (.*)$/i) {
-                $pass ||= $1;
-            }
-            unless ( defined $pass ) {
-                my ($user_obj, $token) = RT::Authen::Token->UserForAuthString($pass, $user);
-                if ( $user_obj ) {
-                    # log in
-                    my $remote_addr = $get_env->('REMOTE_ADDR');
-                    $RT::Logger->info("Successful login for @{[$user_obj->Name]} from $remote_addr using authentication token #@{[$token->Id]} (\"@{[$token->Description]}\")");
-
-                    # It's important to nab the next page from the session before we blow
-                    # the session away
-                    my $next = RT::Interface::Web::RemoveNextPage($ARGS->{'next'});
-                    $next = $next->{'url'} if ref $next;
-
-                    RT::Interface::Web::InstantiateNewSession();
-                    $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
-
-                    # Really the only time we don't want to redirect here is if we were
-                    # passed user and pass as query params in the URL.
-                    if ($next) {
-                        RT::Interface::Web::Redirect($next);
-                    }
-                    elsif ($ARGS->{'next'}) {
-                        # Invalid hash, but still wants to go somewhere, take them to /
-                        RT::Interface::Web::Redirect(RT->Config->Get('WebURL'));
-                    }
-                }
-            }
-        }
+        AttemptTokenAuthentification($ARGS);
 
         unless ($authed) {
             my $m = $HTML::Mason::Commands::m;
@@ -909,6 +869,50 @@ sub AttemptPasswordAuthentication {
     }
 }
 
+sub AttemptTokenAuthentification {
+    my $ARGS = shift;
+    return if RT::Interface::Web::_UserLoggedIn();
+
+    my $get_env = sub {
+        my $key = shift;
+        if (RT::Interface::Web->can('RequestENV')) {
+            return RT::Interface::Web::RequestENV($key)
+        }
+        return $ENV{$key};
+    };
+
+    my ($pass, $user) = ('', '');
+    if (($get_env->('HTTP_AUTHORIZATION')||'') =~ /^token (.*)$/i) {
+        $pass ||= $1;
+    }
+    return unless defined $pass;
+
+    my ($user_obj, $token) = RT::Authen::Token->UserForAuthString($pass, $user);
+    if ( $user_obj ) {
+        # log in
+        my $remote_addr = $get_env->('REMOTE_ADDR');
+        $RT::Logger->info("Successful login for @{[$user_obj->Name]} from $remote_addr using authentication token #@{[$token->Id]} (\"@{[$token->Description]}\")");
+
+        # It's important to nab the next page from the session before we blow
+        # the session away
+        my $next = RT::Interface::Web::RemoveNextPage($ARGS->{'next'});
+        $next = $next->{'url'} if ref $next;
+
+        RT::Interface::Web::InstantiateNewSession();
+        $HTML::Mason::Commands::session{'CurrentUser'} = $user_obj;
+
+        # Really the only time we don't want to redirect here is if we were
+        # passed user and pass as query params in the URL.
+        if ($next) {
+            RT::Interface::Web::Redirect($next);
+        }
+        elsif ($ARGS->{'next'}) {
+            # Invalid hash, but still wants to go somewhere, take them to /
+            RT::Interface::Web::Redirect(RT->Config->Get('WebURL'));
+        }
+    }
+}
+
 =head2 LoadSessionFromCookie
 
 Load or setup a session cookie for the current user.
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 42740c32cc..4cf0fd111b 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -917,6 +917,13 @@ sub BuildMainNav {
         $page->child( history => title => loc('History'), path => "/Admin/Tools/ConfigHistory.html" );
     }
 
+    if ( $request_path =~ m{^/Prefs/AuthTokens} ) {
+        $page->child( select_auth_token => title => loc('Select'), path => '/Prefs/AuthTokens.html');
+        $page->child( create_auth_token => title => loc('Create'),
+            raw_html => q[<a class="btn menu-item" href="#create-auth-token" data-toggle="modal" rel="modal:open">].loc("Create")."</a>"
+        );
+    }
+
     # due to historical reasons of always having been in /Elements/Tabs
     $HTML::Mason::Commands::m->callback( CallbackName => 'Privileged', Path => $request_path, Search_Args => $args, Has_Query => $has_query, ARGSRef => \%args, CallbackPage => '/Elements/Tabs' );
 }
@@ -1346,6 +1353,16 @@ sub _BuildAdminMenu {
                     $page->child( keys    => title => loc('Private keys'),   path => "/Admin/Users/Keys.html?id=" . $id );
                 }
                 $page->child( 'summary'   => title => loc('User Summary'),   path => "/User/Summary.html?id=" . $id );
+
+                my $auth_tokens = $page->child(auth_tokens => title => loc('Auth Tokens'), path => '/Admin/Users/AuthTokens.html?id=' . $id);
+
+                # Only show the create option on the auth token select page because we use a modal
+                if ( $request_path =~ m{^(/Admin/Users/AuthTokens.html)} and $admin->child("users") ) {
+                    $auth_tokens->child( select_auth_token => title => loc('Select'), path => '/Admin/Users/AuthTokens.html?id='.$id);
+                    $auth_tokens->child( create_auth_token => title => loc('Create'),
+                        raw_html => q[<a class="btn menu-item" href="#create-auth-token" data-toggle="modal" rel="modal:open">].loc("Create")."</a>"
+                    );
+                }
             }
         }
 
@@ -1535,20 +1552,6 @@ sub _BuildAdminMenu {
             $page->child( create => title => loc('Create'), path => "/Admin/Articles/Classes/Modify.html?Create=1" );
         }
     }
-
-    my $request_path_token = $request_path =~ s!/{2,}!/!g;
-    if ( $request_path_token =~ m{^(/Admin/Users|/User/(Summary|History)\.html)} and $admin->child("users") ) {
-        if ( $HTML::Mason::Commands::DECODED_ARGS->{'id'} && $HTML::Mason::Commands::DECODED_ARGS->{'id'} =~ /^\d+$/ ) {
-            my $id = $HTML::Mason::Commands::DECODED_ARGS->{'id'};
-            my $obj = RT::User->new( $HTML::Mason::Commands::session{'CurrentUser'} );
-            $obj->Load($id);
-
-            if ( $obj and $obj->id ) {
-                my $tabs = PageMenu();
-                $tabs->child(auth_tokens => title => loc('Auth Tokens'), path => '/Admin/Users/AuthTokens.html?id=' . $id);
-            }
-        }
-    }
 }
 
 sub BuildSelfServiceNav {
diff --git a/share/html/Helpers/AuthToken/Create b/share/html/Admin/Users/AuthToken/Create.html
similarity index 86%
rename from share/html/Helpers/AuthToken/Create
rename to share/html/Admin/Users/AuthToken/Create.html
index 1ff6d3ac0d..97317312c3 100644
--- a/share/html/Helpers/AuthToken/Create
+++ b/share/html/Admin/Users/AuthToken/Create.html
@@ -45,5 +45,19 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<& /Elements/AuthToken/CreateResults, %ARGS &>
-% $m->abort;
+
+<& /Elements/AuthToken/Create, Owner => $UserObj->Id, %ARGS &>
+
+<%ARGS>
+$id => undef
+</%ARGS>
+<%INIT>
+my @results;
+
+my $UserObj = RT::User->new( $session{'CurrentUser'} );
+$UserObj->Load( $id );
+unless ( $UserObj->id ) {
+    Abort( loc("Couldn't load user #[_1]", $id) );
+}
+$id = $ARGS{'id'} = $UserObj->id;
+</%INIT>
diff --git a/share/html/Admin/Users/AuthTokens.html b/share/html/Admin/Users/AuthTokens.html
index 34de303304..e7368099cd 100644
--- a/share/html/Admin/Users/AuthTokens.html
+++ b/share/html/Admin/Users/AuthTokens.html
@@ -45,30 +45,13 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<& /Admin/Elements/Header, Title => loc("[_1]'s authentication tokens",$UserObj->Name)  &>
-<& /Elements/Tabs &>
-<& /Elements/ListActions, actions => \@results &>
 
-<div class="form-row">
-  <div class="auth-tokens col-12">
-    <p><&|/l&>Authentication tokens allow other applications to use your user
-        account without having to share your password, while allowing you to
-       revoke access on an application-specific basis. Changing your password
-        <em>does not</em> invalidate your auth tokens; you must revoke them here.</&>
-    </p>
-  </div>
-</div>
+<& /Elements/Header, Title => loc('My authentication tokens') &>
+<& /Elements/Tabs &>
 
-<& /Elements/AuthToken/CreateButton, %ARGS, Owner => $UserObj->Id &>
-<& /Elements/AuthToken/List, %ARGS, Owner => $UserObj->Id &>
-</div>
+<& /Elements/AuthToken/AuthTokens, Owner => $id, Path => '/Prefs/AuthTokens.html', %ARGS &>
 
-<%ARGS>
-$id => undef
-</%ARGS>
 <%INIT>
-my @results;
-
 my $UserObj = RT::User->new( $session{'CurrentUser'} );
 $UserObj->Load( $id );
 unless ( $UserObj->id ) {
@@ -76,3 +59,7 @@ unless ( $UserObj->id ) {
 }
 $id = $ARGS{'id'} = $UserObj->id;
 </%INIT>
+
+<%ARGS>
+$id => undef
+</%ARGS>
diff --git a/share/html/Elements/AuthToken/AuthTokens b/share/html/Elements/AuthToken/AuthTokens
new file mode 100644
index 0000000000..0a031ff856
--- /dev/null
+++ b/share/html/Elements/AuthToken/AuthTokens
@@ -0,0 +1,146 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<& /Elements/ListActions, actions => \@results &>
+
+<div class="form-row">
+    <div class="col-12">
+    <p><&|/l&>Authentication tokens allow other applications to use your user account without having to share your password, while allowing you to revoke access on an application-specific basis. Changing your password <em>does not</em> invalidate your auth tokens; you must revoke them here.</&></p>
+    </div>
+
+% if ( $Authstring ) {
+    <div class="modal authtoken-success" id="auth-token-auth-string">
+    <div class="modal-dialog modal-dialog-centered" role="document">
+        <div class="modal-content">
+        <div class="modal-header">
+            <&|/l, $Description &>This is your new authentication token. Treat it carefully like a password. Please save it now because you cannot access it again.</&>
+            <a id="auth-token-close-modal" href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">×</span>
+            </a>
+        </div>
+        <div class="col-12 authstring text-center">
+            <span><% $Authstring %></span>
+        </div>
+        </div>
+    </div>
+    </div>
+% }
+
+    <div class="col-12">
+    <& /Elements/AuthToken/Create, Path => $Path, Owner => $Owner &>
+    </div>
+
+    <div class="col-12">
+    <& /Elements/AuthToken/List, Path => $Path, Owner => $Owner &>
+    </div>
+</div>
+
+<%INIT>
+my @results;
+
+if ( $Update || $Revoke ) {
+    my $error = '';
+
+    my $token = RT::Authen::Token::AuthToken->new( $session{CurrentUser} );
+    $token->Load( $ARGS{'Token'} );
+    my ($ok, $msg);
+    if ( $Update ) {
+        if ( !length( $Description ) ) {
+            push @results, loc( "Description cannot be blank." );
+        }
+
+        if ( $Description ne $token->Description ) {
+            ($ok, $msg) = $token->SetDescription( $Description );
+            push @results, $msg;
+        }
+    }
+    elsif ($Revoke) {
+        ($ok, $msg) = $token->Delete;
+        push @results, $msg;
+    }
+}
+
+my ($authstring);
+if ( $CreateToken ) {
+    my $token = RT::Authen::Token::AuthToken->new( $session{CurrentUser} );
+
+    # Don't require password for systems with some form of federated auth
+    my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
+
+    if ( !length( $Description ) ) {
+        push @results, loc("Description cannot be blank.");
+    }
+    elsif ( $res{'CanSet'} && !length( $ARGS{'Password'} ) ) {
+        push @results, loc("Please enter your current password.");
+    }
+    elsif ( $res{'CanSet'} && !$session{CurrentUser}->IsPassword($ARGS{'Password'} ) ) {
+        push @results, loc("Please enter your current password correctly.");
+    }
+    else {
+        ((my $ok), (my $msg), $Authstring) = $token->Create(
+            Owner       => $Owner,
+            Description => $Description,
+        );
+        if ( $ok ) {
+            push @results, loc( "New token successfully created" );
+        }
+        else {
+            push @results, loc( "Something went wrong" );
+        }
+    }
+}
+</%INIT>
+
+<%ARGS>
+$Path
+$Owner
+$Update      => 0
+$Revoke      => 0
+$CreateToken => 0
+$Authstring  => ''
+$Description => ''
+</%ARGS>
diff --git a/share/html/Elements/AuthToken/CreateForm b/share/html/Elements/AuthToken/Create
similarity index 84%
rename from share/html/Elements/AuthToken/CreateForm
rename to share/html/Elements/AuthToken/Create
index 8a73fb9b5d..bc4409657a 100644
--- a/share/html/Elements/AuthToken/CreateForm
+++ b/share/html/Elements/AuthToken/Create
@@ -45,26 +45,17 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$Owner
-$Description => ''
-</%ARGS>
-<%INIT>
-# Don't require password for systems with some form of federated auth
-my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
-</%INIT>
-
 <div class="modal" id="create-auth-token">
   <div class="modal-dialog modal-dialog-centered" role="document">
     <div class="modal-content">
         <div class="modal-header">
         <h5 class="modal-title"><&|/l&>Create auth token</&></h5>
-        <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
-            <span aria-hidden="true">×</span>
+        <a id="auth-token-close-modal" href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
+          <span aria-hidden="true">×</span>
         </a>
         </div>
         <div class="modal-body">
-          <form class="authtoken-form" method="post" data-ajax-url="<% RT->Config->Get('WebPath') %>/Helpers/AuthToken/Create">
+          <form class="authtoken-form" method="POST" action="<% RT->Config->Get('WebPath') . $Path %>">
             <div class="form-row">
               <input type="hidden" name="Owner" value="<% $Owner %>">
 % if ( $res{'CanSet'} ){
@@ -88,16 +79,19 @@ my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
                 <& /Elements/Submit, Label => loc("Create"), Name => 'CreateToken' &>
               </div>
             </div>
-
-            <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
           </form>
         </div>
     </div>
   </div>
 </div>
 
-<div class="form-row">
-  <div class="col-12">
-    <a class="button btn btn-primary" href="#create-auth-token" data-toggle="modal" rel="modal:open" name="create_auth_token"><&|/l&>Create Auth Token</&></a>
-  </div>
-</div>
+<%INIT>
+# Don't require password for systems with some form of federated auth
+my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
+</%INIT>
+
+<%ARGS>
+$Path
+$Owner
+$Description => ''
+</%ARGS>
diff --git a/share/html/Elements/AuthToken/CreateButton b/share/html/Elements/AuthToken/CreateButton
deleted file mode 100644
index 58c386e302..0000000000
--- a/share/html/Elements/AuthToken/CreateButton
+++ /dev/null
@@ -1,72 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$Owner
-$ShowCreateForm => 0
-$CreateToken => 0
-</%ARGS>
-<%INIT>
-</%INIT>
-% if ($CreateToken) {
-  <&| /Widgets/TitleBox, title => loc("Create Auth Token") &>
-    <& /Elements/AuthToken/CreateResults, %ARGS &>
-  </&>
-% } elsif ($ShowCreateForm) {
-  <&| /Widgets/TitleBox, title => loc("Create Auth Token") &>
-    <& /Elements/AuthToken/CreateForm, Owner => $Owner &>
-  </&>
-% } else {
-<div class="authtoken-form-container">
-  <& /Elements/AuthToken/CreateForm, Owner => $Owner &>
-</div>
-<form method="GET">
-  <input type="hidden" name="ShowCreateForm" value="1">
-  <input type="hidden" name="id" value="<% $Owner %>">
-  <button type="submit" class="authtoken-create">Create Auth Token</button>
-</form>
-% }
diff --git a/share/html/Elements/AuthToken/CreateResults b/share/html/Elements/AuthToken/CreateResults
deleted file mode 100644
index 58bb9da202..0000000000
--- a/share/html/Elements/AuthToken/CreateResults
+++ /dev/null
@@ -1,96 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$Owner => undef
-$Password => ''
-$Description => ''
-</%ARGS>
-<%INIT>
-my $token = RT::AuthToken->new($session{CurrentUser});
-# Don't require password for systems with some form of federated auth
-my %res = $session{'CurrentUser'}->CurrentUserRequireToSetPassword();
-my ($error, $authstring);
-
-if (!$Owner) {
-    $error = loc("Owner required. Please refresh the page and try again.");
-}
-elsif (!length($Description)) {
-    $error = loc("Description cannot be blank.");
-}
-elsif ($res{'CanSet'} && !length($Password)) {
-    $error = loc("Please enter your current password.");
-}
-elsif ($res{'CanSet'} && !$session{CurrentUser}->IsPassword($Password) ) {
-    $error = loc("Please enter your current password correctly.");
-}
-else {
-    ((my $ok), (my $msg), $authstring) = $token->Create(
-        Owner       => $Owner,
-        Description => $Description,
-    );
-}
-</%INIT>
-<div class="form-row">
-% if ($error) {
-  <& /Elements/AuthToken/CreateForm, Owner => $Owner, Error => $error, Description => $Description &>
-% } else {
-    <div class="authtoken-success">
-      <div class="col-12">
-        <p><&|/l, $Description&>This is your new authentication token. Treat
-            it carefully like a password. Please save it now because you cannot
-            access it again.
-        </&></p>
-      </div>
-      <div class="col-12 text-center">
-        <span class="authstring"><% $authstring %></span>
-      </div>
-    </div>
-  </div>
-</div>
-% }
-
diff --git a/share/html/Elements/AuthToken/List b/share/html/Elements/AuthToken/List
index c6f729c295..774e089bc0 100644
--- a/share/html/Elements/AuthToken/List
+++ b/share/html/Elements/AuthToken/List
@@ -45,13 +45,7 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$Owner
-</%ARGS>
-<%INIT>
-my $tokens = RT::Authen::Token::AuthTokens->new($session{CurrentUser});
-$tokens->LimitOwner(VALUE => $Owner);
-</%INIT>
+
 <div class="authtoken-list" data-owner="<% $Owner %>">
   <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
 % if ($tokens->Count == 0) {
@@ -81,7 +75,7 @@ $tokens->LimitOwner(VALUE => $Owner);
             <& /Elements/AuthToken/ModifyResults, %ARGS, Token => $token->Id, Owner => $Owner &>
 % }
 % }
-            <form class="authtoken-form" method="post" data-ajax-url="<% RT->Config->Get('WebPath') %>/Helpers/AuthToken/Modify" action="<% RT->Config->Get('WebPath') %><% $r->uri %>">
+            <form class="authtoken-form" method="post" action="<% RT->Config->Get('WebPath') . $Path %>">
               <div class="form-row">
 % if ($ARGS{id}) {
                 <input type="hidden" name="id" value="<% $ARGS{id} %>">
@@ -136,3 +130,13 @@ $tokens->LimitOwner(VALUE => $Owner);
   </ul>
 % }
 </div>
+
+<%INIT>
+my $tokens = RT::Authen::Token::AuthTokens->new($session{CurrentUser});
+$tokens->LimitOwner(VALUE => $Owner);
+</%INIT>
+
+<%ARGS>
+$Owner
+$Path
+</%ARGS>
diff --git a/share/html/Elements/AuthToken/ModifyForm b/share/html/Elements/AuthToken/ModifyForm
deleted file mode 100644
index c57dbc10f1..0000000000
--- a/share/html/Elements/AuthToken/ModifyForm
+++ /dev/null
@@ -1,123 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$Token => undef
-$TokenObj => undef
-$Error => ''
-</%ARGS>
-<%INIT>
-if (!$TokenObj) {
-    $TokenObj = RT::Authen::Token::AuthToken->new($session{CurrentUser});
-    $TokenObj->Load($Token);
-}
-Abort("Unable to load authentication token") if !$TokenObj->Id;
-Abort("Permission Denied") if !$TokenObj->CurrentUserCanSee;
-</%INIT>
-
-<div class="modal" id="edit-auth-token">
-  <div class="modal-dialog modal-dialog-centered" role="document">
-    <div class="modal-content">
-      <div class="modal-header">
-        <h5 class="modal-title"><&|/l&>Edit auth token</&></h5>
-        <a href="javascript:void(0)" class="close" data-dismiss="modal" aria-label="Close">
-          <span aria-hidden="true">×</span>
-        </a>
-      </div>
-      <div class="modal-body">
-        <form class="authtoken-form" method="post" data-ajax-url="<% RT->Config->Get('WebPath') %>/Helpers/AuthToken/Modify" action="<% RT->Config->Get('WebPath') %><% $r->uri %>">
-          <div class="form-row">
-% if ($Error) {
-            <div class="col-12">
-              <p class="error"><% $Error %></p>
-            </div>
-% }
-% if ($ARGS{id}) {
-            <input type="hidden" name="id" value="<% $ARGS{id} %>">
-% }
-            <input type="hidden" name="Token" value="<% $TokenObj->id %>">
-
-            <div class="form-row">
-              <div class="col-4 label">
-                <&|/l&>Description</&>:<br><em><&|/l&>What's this token for?</&></em>
-              </div>
-              <div class="col-8 value">
-                <input class="form-control" type="text" name="Description" value="<% $ARGS{Description} // $TokenObj->Description %>" size="16" />
-              </div>
-
-              <div class="col-4 label">
-                <&|/l&>Last Used</&>:
-              </div>
-              <div class="col-8 value">
-% my $used = $TokenObj->LastUsedObj;
-% if ($used->IsSet) {
-                <% $used->AgeAsString %>
-% } else {
-                <&|/l&>never</&>
-% }
-              </div>
-
-              <div class="col-4 label">
-                <&|/l&>Created</&>:
-              </div>
-              <div class="col-8 value">
-                <% $TokenObj->CreatedObj->AgeAsString %>
-              </div>
-            </div>
-
-            <div class="buttons">
-              <input type="submit" name="Update" value="<&|/l&>Save</&>"></input>
-              <input type="submit" name="Revoke" value="<&|/l&>Revoke</&>"></input>
-            </div>
-
-            <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
-          </div>
-        </form>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/share/html/Elements/AuthToken/ModifyResults b/share/html/Elements/AuthToken/ModifyResults
deleted file mode 100644
index 0bb6ac7a72..0000000000
--- a/share/html/Elements/AuthToken/ModifyResults
+++ /dev/null
@@ -1,83 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$Token
-$Description => ''
-$Update => 0
-$Revoke => 0
-</%ARGS>
-<%INIT>
-my $TokenObj = RT::Authen::Token::AuthToken->new($session{CurrentUser});
-$TokenObj->Load($Token);
-my ($error, $ok, $msg);
-
-if ($Update) {
-    if (!length($Description)) {
-        $error = loc("Description cannot be blank.");
-    }
-
-    if ($Description ne $TokenObj->Description) {
-        ($ok, $msg) = $TokenObj->SetDescription($Description);
-        $error = $msg if !$ok;
-    }
-}
-elsif ($Revoke) {
-    ($ok, $msg) = $TokenObj->Delete;
-}
-</%INIT>
-<div id="messages" class="auth-token-messages col-6">
-% if ( $error ) {
-  <div class="alert alert-danger" role="alert">
-    <% $error %>
-  </div>
-% } if ( $msg ) {
-    <div class="alert alert-success" role="alert">
-      <% $msg %>
-    </div>
-% }
-</div>
diff --git a/share/html/Helpers/AuthToken/List b/share/html/Helpers/AuthToken/List
deleted file mode 100644
index 041fdf13c8..0000000000
--- a/share/html/Helpers/AuthToken/List
+++ /dev/null
@@ -1,49 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<& /Elements/AuthToken/List, Owner => $ARGS{owner} &>
-% $m->abort;
diff --git a/share/html/Helpers/AuthToken/Modify b/share/html/Helpers/AuthToken/Modify
deleted file mode 100644
index 73a88f2a9d..0000000000
--- a/share/html/Helpers/AuthToken/Modify
+++ /dev/null
@@ -1,49 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<& /Elements/AuthToken/ModifyResults, %ARGS &>
-% $m->abort;
diff --git a/share/html/Prefs/AuthTokens.html b/share/html/Prefs/AuthTokens.html
index 9d9e8b4d47..9bac418470 100644
--- a/share/html/Prefs/AuthTokens.html
+++ b/share/html/Prefs/AuthTokens.html
@@ -45,32 +45,12 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
+
 <& /Elements/Header, Title => loc('My authentication tokens') &>
 <& /Elements/Tabs &>
-<& /Elements/ListActions, actions => \@results &>
-
-<div class="form-row">
-  <div class="col-12">
-    <p><&|/l&>Authentication tokens allow other applications to use your user
-        account without having to share your password, while allowing you to
-       revoke access on an application-specific basis. Changing your password
-        <em>does not</em> invalidate your auth tokens; you must revoke them here.
-    </&></p>
-  </div>
-
-  <div class="col-12">
-    <& /Elements/AuthToken/CreateForm, %ARGS, Owner => $UserObj->Id &>
-  </div>
-
-  <div id="auth-token-messages" class="auth-token-messages col-12">
-  </div>
 
-  <div class="col-12">
-    <& /Elements/AuthToken/List, %ARGS, Owner => $UserObj->Id &>
-  </div>
-</div>
+<& /Elements/AuthToken/AuthTokens, Owner => $owner, Path => '/Prefs/AuthTokens.html', %ARGS &>
 
 <%INIT>
-my @results;
-my $UserObj = $session{'CurrentUser'}->UserObj;
+my $owner = $session{'CurrentUser'}->UserObj->Id;
 </%INIT>
diff --git a/share/static/css/elevator-light/rt-authen-token.css b/share/static/css/elevator-light/rt-authen-token.css
index 80f8ccadb1..4c4163201e 100644
--- a/share/static/css/elevator-light/rt-authen-token.css
+++ b/share/static/css/elevator-light/rt-authen-token.css
@@ -68,6 +68,3 @@
     float: left;
 }
 
-.auth-token-messages {
-    margin: 1rem 0rem 0rem 0px;
-}
diff --git a/share/static/images/loading.gif b/share/static/images/loading.gif
deleted file mode 100644
index 3288d1035d..0000000000
Binary files a/share/static/images/loading.gif and /dev/null differ
diff --git a/share/static/js/rt-authen-token.js b/share/static/js/rt-authen-token.js
index e989851335..a1fbb6a2a9 100644
--- a/share/static/js/rt-authen-token.js
+++ b/share/static/js/rt-authen-token.js
@@ -1,67 +1,3 @@
 jQuery(function() {
-    var refreshTokenList = function () {
-        var list = jQuery('.authtoken-list');
-        jQuery.post(
-            RT.Config.WebHomePath + "/Helpers/AuthToken/List",
-            list.data(),
-            function (data) {
-                list.replaceWith(data);
-            }
-        );
-    };
-
-    var submitForm = function (form, extraParams) {
-        var payload = form.serializeArray();
-        var name = extraParams[0].name;
-
-        if (extraParams) {
-            Array.prototype.push.apply(payload, extraParams);
-        }
-
-        form.addClass('submitting');
-        form.find('input').attr('disabled', true);
-
-        var renderResult = function(name, html) {
-            if ( name === 'CreateToken' ) {
-                var form = jQuery('.modal .authtoken-form');
-                if (form.length) {
-                    form.replaceWith(html);
-                }
-                else {
-                    jQuery('#body').append(html);
-                }
-            }
-            else {
-                jQuery('#auth-token-messages').replaceWith(html);
-            }
-            refreshTokenList();
-        };
-
-        jQuery.ajax({
-            method: 'POST',
-            url: form.data('ajax-url'),
-            data: payload,
-            timeout: 30000, /* 30 seconds */
-            success: function (data, status) {
-                renderResult(name, data);
-            },
-            error: function (xhr, status, error) {
-                renderResult("<p>An error has occurred. Please refresh the page and try again.<p>");
-            }
-        });
-    };
-
-    jQuery('body').on('click', '.authtoken-form button, .authtoken-form input[type=submit]', function (e) {
-        e.preventDefault();
-        var button = jQuery(this);
-
-        var params = [{ name: button.attr('name'), value: button.attr('value') }];
-        submitForm(button.closest('form'), params);
-    });
-
-    jQuery('body').on('submit', '.authtoken-form', function (e) {
-        e.preventDefault();
-        submitForm(jQuery(this));
-    });
+    jQuery('#auth-token-auth-string').modal('show');
 });
-

commit 3d6983609b6fb65e3b03a8d74aa36f2ea9cdb5f4
Author: Craig <craig at bestpractical.com>
Date:   Thu May 7 14:18:35 2020 -0400

    Add documentation for using token auth

diff --git a/docs/authentication.pod b/docs/authentication.pod
index 882805ed21..4552f856fd 100644
--- a/docs/authentication.pod
+++ b/docs/authentication.pod
@@ -86,6 +86,11 @@ An example of using LDAP authentication and HTTP Basic auth:
     </Location>
 
 
+If you using token authorization then add the following directive to your RT
+Apache configuration to allow RT to access the Authorization header.
+
+    SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
+
 =head3 RT Configuration Options
 
 All of the following options control the behavior of RT's built-in external
diff --git a/docs/web_deployment.pod b/docs/web_deployment.pod
index 779401f985..c46933971f 100644
--- a/docs/web_deployment.pod
+++ b/docs/web_deployment.pod
@@ -32,6 +32,11 @@ spontaneously logged in as other users in the system.
 See also L<authentication/Apache configuration>, in case you intend to
 use Apache to provide authentication.
 
+If you using token authorization then add the following directive to your RT
+Apache configuration to allow RT to access the Authorization header.
+
+    SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
+
 =head3 mod_fcgid
 
 B<WARNING>: Before mod_fcgid 2.3.6, the maximum request size was 1GB.
diff --git a/lib/RT/Authen/Token.pm b/lib/RT/Authen/Token.pm
index cf9c824d89..e7358b47c8 100644
--- a/lib/RT/Authen/Token.pm
+++ b/lib/RT/Authen/Token.pm
@@ -92,6 +92,8 @@ sub UserForAuthString {
 
 RT-Authen-Token - token-based authentication
 
+=cut
+
 =head1 DESCRIPTION
 
 Allow for users to generate and login with authentication tokens.
@@ -116,7 +118,7 @@ Authentication tokens are stored securely (hashed and salted) in the
 database just like passwords, and so cannot be recovered after they are
 generated.
 
-=item Update your Apache configuration
+=head2 Update your Apache configuration
 
 If you are running RT under Apache, add the following directive to your RT
 Apache configuration to allow RT to access the Authorization header.

-----------------------------------------------------------------------


More information about the rt-commit mailing list