diff --git a/src/js/_enqueues/admin/application-passwords.js b/src/js/_enqueues/admin/application-passwords.js
index c79cdb8b4037f..e59402c3e3071 100644
--- a/src/js/_enqueues/admin/application-passwords.js
+++ b/src/js/_enqueues/admin/application-passwords.js
@@ -5,7 +5,8 @@
( function( $ ) {
var $appPassSection = $( '#application-passwords-section' ),
$newAppPassForm = $appPassSection.find( '.create-application-password' ),
- $newAppPassField = $newAppPassForm.find( '.input' ),
+ $newAppPassField = $newAppPassForm.find( '#new_application_password_name' ),
+ $newAppPassExpiresField = $newAppPassForm.find( '#new_application_password_expires' ),
$newAppPassButton = $newAppPassForm.find( '.button' ),
$appPassTwrapper = $appPassSection.find( '.application-passwords-list-table-wrapper' ),
$appPassTbody = $appPassSection.find( 'tbody' ),
@@ -36,6 +37,15 @@
name: name
};
+ var expires = $newAppPassExpiresField.val();
+ if ( expires ) {
+ var expiresDate = new Date( expires );
+
+ if ( ! isNaN( expiresDate.getTime() ) ) {
+ request.expires = expiresDate.toISOString();
+ }
+ }
+
/**
* Filters the request data used to create a new Application Password.
*
@@ -54,6 +64,7 @@
$newAppPassButton.removeProp( 'aria-disabled' ).removeClass( 'disabled' );
} ).done( function( response ) {
$newAppPassField.val( '' );
+ $newAppPassExpiresField.val( '' );
$newAppPassButton.prop( 'disabled', false );
$newAppPassForm.after( tmplNewAppPass( {
@@ -77,6 +88,85 @@
*/
wp.hooks.doAction( 'wp_application_passwords_created_password', response, request );
} ).fail( handleErrorResponse );
+ });
+
+ /**
+ * Handles the inline editing of an application password expiration date.
+ * * @since 7.1.0
+ */
+ $appPassTbody.on( 'click', '.edit-expires', function( e ) {
+ e.preventDefault();
+
+ var $button = $( this ),
+ $tr = $button.closest( 'tr' ),
+ uuid = $tr.data( 'uuid' ),
+ currentExpires = $tr.data( 'expires' ),
+ $td = $button.closest( 'td' );
+
+ if ( $td.find( '.edit-expires-form' ).length ) {
+ return;
+ }
+
+ var $form = $( '
' );
+ var $input = $( '' );
+
+ if ( currentExpires ) {
+ $input.val( currentExpires.split( 'T' )[0] );
+ }
+
+ var $buttonContainer = $( '' );
+ var $saveBtn = $( '' );
+ var $cancelBtn = $( '' );
+
+ $buttonContainer.append( $saveBtn ).append( $cancelBtn );
+ $form.append( $input ).append( $buttonContainer );
+
+ $td.append( $form );
+ $button.hide();
+ $input.trigger( 'focus' );
+
+ // Close form on Escape key.
+ $input.on( 'keydown', function( e ) {
+ if ( 27 === e.which ) {
+ $cancelBtn.trigger( 'click' );
+ }
+ if ( 13 === e.which ) {
+ e.preventDefault();
+ $saveBtn.trigger( 'click' );
+ }
+ });
+
+ $cancelBtn.on( 'click', function() {
+ $form.remove();
+ $button.show().trigger( 'focus' );
+ } );
+
+ $saveBtn.on( 'click', function() {
+ var newExpires = $input.val();
+ var expiresDate = newExpires ? new Date( newExpires ) : null;
+ var requestData = {
+ expires: ( expiresDate && ! isNaN( expiresDate.getTime() ) ) ? expiresDate.toISOString() : null
+ };
+
+ clearNotices();
+ $saveBtn.prop( 'disabled', true );
+ $cancelBtn.prop( 'disabled', true );
+
+ wp.apiRequest( {
+ path: '/wp/v2/users/' + userId + '/application-passwords/' + uuid + '?_locale=user',
+ method: 'PUT',
+ data: JSON.stringify( requestData ),
+ contentType: 'application/json'
+ } ).always( function() {
+ $saveBtn.prop( 'disabled', false );
+ $cancelBtn.prop( 'disabled', false );
+ } ).done( function( response ) {
+ var $newRow = $( tmplAppPassRow( response ) );
+ $tr.replaceWith( $newRow );
+ $newRow.find( '.edit-expires' ).trigger( 'focus' );
+ addNotice( wp.i18n.__( 'Application password expiration updated.' ), 'success' );
+ } ).fail( handleErrorResponse );
+ } );
} );
$appPassTbody.on( 'click', '.delete', function( e ) {
diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css
index b48825a1ef5a3..187215c9c0cfb 100644
--- a/src/wp-admin/css/forms.css
+++ b/src/wp-admin/css/forms.css
@@ -1077,6 +1077,28 @@ table.form-table td .updated p {
max-width: 20em;
}
+.edit-expires-form {
+ margin-top: 8px;
+ width: 100%;
+}
+
+.edit-expires-input {
+ display: block;
+ width: 100%;
+ max-width: 150px;
+ margin-bottom: 5px;
+}
+
+.edit-expires-button-group {
+ display: flex;
+ gap: 4px;
+ justify-content: flex-start;
+}
+
+.column-expires {
+ vertical-align: top;
+}
+
.auth-app-card.card {
max-width: 768px;
}
diff --git a/src/wp-admin/includes/class-wp-application-passwords-list-table.php b/src/wp-admin/includes/class-wp-application-passwords-list-table.php
index 9a60853016fc5..a15495e881009 100644
--- a/src/wp-admin/includes/class-wp-application-passwords-list-table.php
+++ b/src/wp-admin/includes/class-wp-application-passwords-list-table.php
@@ -29,6 +29,7 @@ public function get_columns() {
'created' => __( 'Created' ),
'last_used' => __( 'Last Used' ),
'last_ip' => __( 'Last IP' ),
+ 'expires' => __( 'Expires' ),
'revoke' => __( 'Revoke' ),
);
}
@@ -101,6 +102,35 @@ public function column_last_ip( $item ) {
}
}
+ /**
+ * Handles the expires column output.
+ *
+ * @since 7.1.0
+ *
+ * @param array $item The current application password item.
+ */
+ public function column_expires( $item ) {
+ if ( empty( $item['expires'] ) ) {
+ echo '—';
+ } else {
+ $date = date_i18n( __( 'F j, Y' ), $item['expires'] );
+ if ( time() > $item['expires'] ) {
+ printf(
+ '%s',
+ /* translators: %s: Expiration date for the Application Password. */
+ sprintf( __( 'Expired on %s' ), $date )
+ );
+ } else {
+ echo $date;
+ }
+ }
+ printf(
+ '
',
+ esc_attr__( 'Edit Application Password Expiration Date' ),
+ esc_html__( 'Edit Expiry' )
+ );
+ }
+
/**
* Handles the revoke column output.
*
@@ -230,6 +260,25 @@ public function print_js_template_row() {
case 'last_ip':
echo "{{ data.last_ip || '—' }}";
break;
+ case 'expires':
+ ?>
+ <# if ( data.expires ) { #>
+ <# var isExpired = new Date().getTime() > new Date( data.expires ).getTime(); #>
+ <# var formattedDate = wp.date.dateI18n( , data.expires ); #>
+ <# if ( isExpired ) { #>
+
+ <# } else { #>
+ {{ formattedDate }}
+ <# } #>
+ <# } else { #>
+ —
+ <# } #>
+
+ %2$s',
diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php
index c25380a93ee91..84fe33f698013 100644
--- a/src/wp-admin/user-edit.php
+++ b/src/wp-admin/user-edit.php
@@ -837,6 +837,12 @@
+
+
time(),
'last_used' => null,
'last_ip' => null,
+ 'expires' => isset( $args['expires'] ) ? (int) $args['expires'] : null,
);
$passwords = static::get_user_application_passwords( $user_id );
@@ -282,6 +283,14 @@ public static function update_application_password( $user_id, $uuid, $update = a
$save = false;
+ if ( array_key_exists( 'expires', $update ) ) {
+ $expires = null === $update['expires'] ? null : (int) $update['expires'];
+ if ( ! array_key_exists( 'expires', $item ) || $item['expires'] !== $expires ) {
+ $item['expires'] = $expires;
+ $save = true;
+ }
+ }
+
if ( ! empty( $update['name'] ) && $item['name'] !== $update['name'] ) {
$item['name'] = $update['name'];
$save = true;
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php
index 767917d6f6fd0..bf08d4aa52a29 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php
@@ -583,6 +583,11 @@ protected function prepare_item_for_database( $request ) {
$prepared->app_id = $request['app_id'];
}
+ $prepared->expires = null;
+ if ( isset( $request['expires'] ) && null !== $request['expires'] ) {
+ $prepared->expires = rest_parse_date( $request['expires'] );
+ }
+
/**
* Filters an application password before it is inserted via the REST API.
*
@@ -619,6 +624,7 @@ public function prepare_item_for_response( $item, $request ) {
'created' => gmdate( 'Y-m-d\TH:i:s', $item['created'] ),
'last_used' => $item['last_used'] ? gmdate( 'Y-m-d\TH:i:s', $item['last_used'] ) : null,
'last_ip' => $item['last_ip'] ? $item['last_ip'] : null,
+ 'expires' => ! empty( $item['expires'] ) ? gmdate( 'Y-m-d\TH:i:s', $item['expires'] ) : null,
);
if ( isset( $item['new_password'] ) ) {
@@ -849,6 +855,12 @@ public function get_item_schema() {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
+ 'expires' => array(
+ 'description' => __( 'The GMT date the application password expires.' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'context' => array( 'view', 'edit' ),
+ ),
),
);
diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php
index 9c635f63d288a..ec9347dd6eaa1 100644
--- a/src/wp-includes/user.php
+++ b/src/wp-includes/user.php
@@ -463,6 +463,13 @@ function wp_authenticate_application_password(
$error = new WP_Error();
+ if ( ! empty( $item['expires'] ) && time() > $item['expires'] ) {
+ $error->add(
+ 'application_password_expired',
+ __( 'The provided application password has expired.' )
+ );
+ }
+
/**
* Fires when an application password has been successfully checked as valid.
*
diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js
index f6c5cf7a781b7..27ee00feb8afc 100644
--- a/tests/e2e/specs/profile/applications-passwords.test.js
+++ b/tests/e2e/specs/profile/applications-passwords.test.js
@@ -40,6 +40,56 @@ test.describe( 'Manage applications passwords', () => {
);
} );
+ test('should correctly create a new application password with expiration', async ( {
+ page,
+ applicationPasswords
+ } ) => {
+ const expiresDate = new Date();
+ expiresDate.setDate( expiresDate.getDate() + 7 );
+ const expiresString = expiresDate.toISOString().split( 'T' )[ 0 ];
+
+ await applicationPasswords.create( TEST_APPLICATION_NAME, expiresString );
+
+ const [ app ] = await applicationPasswords.get();
+ expect( app['name'] ).toBe( TEST_APPLICATION_NAME );
+ expect( app['expires'] ).not.toBeNull();
+ expect( app['expires'].startsWith( expiresString ) ).toBe( true );
+
+ const successMessage = page.getByRole( 'alert' );
+ await expect( successMessage ).toHaveClass( /notice-success/ );
+ } );
+
+ test('should correctly update an application password expiration date', async ( {
+ page,
+ applicationPasswords
+ } ) => {
+ await applicationPasswords.create();
+
+ const [ app ] = await applicationPasswords.get();
+ expect( app['expires'] ).toBeNull();
+
+ const editButton = page.getByRole( 'button', { name: 'Edit Expiration Date' } );
+ await expect( editButton ).toBeVisible();
+ await editButton.click();
+
+ const expiresInput = page.locator( '.edit-expires-input' );
+ await expect( expiresInput ).toBeVisible();
+
+ const expiresDate = new Date();
+ expiresDate.setDate( expiresDate.getDate() + 10 );
+ const expiresString = expiresDate.toISOString().split( 'T' )[ 0 ];
+ await expiresInput.fill( expiresString );
+
+ const saveButton = page.getByRole( 'button', { name: 'Save' } );
+ await saveButton.click();
+
+ await expect( page.getByRole( 'alert' ) ).toContainText( 'Application password expiration updated.' );
+
+ const [ updatedApp ] = await applicationPasswords.get();
+ expect( updatedApp['expires'] ).not.toBeNull();
+ expect( updatedApp['expires'].startsWith( expiresString ) ).toBe( true );
+ } );
+
test( 'should correctly revoke a single application password', async ( {
page,
applicationPasswords
@@ -94,13 +144,19 @@ class ApplicationPasswords {
this.admin = admin;
}
- async create(applicationName = TEST_APPLICATION_NAME) {
+ async create(applicationName = TEST_APPLICATION_NAME, expires = null) {
await this.admin.visitAdminPage( '/profile.php' );
const newPasswordField = this.page.getByRole( 'textbox', { name: 'New Application Password Name' } );
await expect( newPasswordField ).toBeVisible();
await newPasswordField.fill( applicationName );
+ if ( expires ) {
+ const newPasswordExpiresField = this.page.getByLabel( 'Expires on' );
+ await expect( newPasswordExpiresField ).toBeVisible();
+ await newPasswordExpiresField.fill( expires );
+ }
+
await this.page.getByRole( 'button', { name: 'Add Application Password' } ).click();
await expect( this.page.getByRole( 'alert' ) ).toBeVisible();
}
diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php
index a290d11e118e6..1fdb846c707ac 100644
--- a/tests/phpunit/tests/auth.php
+++ b/tests/phpunit/tests/auth.php
@@ -1767,6 +1767,26 @@ public function test_authenticate_application_password_chunked() {
$this->assertSame( self::$user_id, $user->ID );
}
+ /**
+ * @ticket 53995
+ */
+ public function test_authenticate_application_password_expired() {
+ add_filter( 'application_password_is_api_request', '__return_true' );
+ add_filter( 'wp_is_application_passwords_available', '__return_true' );
+
+ list( $password ) = WP_Application_Passwords::create_new_application_password(
+ self::$user_id,
+ array(
+ 'name' => 'phpunit',
+ 'expires' => time() - DAY_IN_SECONDS,
+ )
+ );
+
+ $error = wp_authenticate_application_password( null, self::$_user->user_login, $password );
+ $this->assertWPError( $error );
+ $this->assertSame( 'application_password_expired', $error->get_error_code() );
+ }
+
/**
* @ticket 51939
*/
diff --git a/tests/phpunit/tests/rest-api/application-passwords.php b/tests/phpunit/tests/rest-api/application-passwords.php
index 65e3bf222d85f..a831407075bb5 100644
--- a/tests/phpunit/tests/rest-api/application-passwords.php
+++ b/tests/phpunit/tests/rest-api/application-passwords.php
@@ -95,7 +95,7 @@ public function test_create_new_application_password( array $args, array $names
$this->assertNotEmpty( $new_password );
$this->assertSame(
- array( 'uuid', 'app_id', 'name', 'password', 'created', 'last_used', 'last_ip' ),
+ array( 'uuid', 'app_id', 'name', 'password', 'created', 'last_used', 'last_ip', 'expires' ),
array_keys( $new_item )
);
$this->assertSame( $args['name'], $new_item['name'] );
@@ -106,10 +106,16 @@ public function data_create_new_application_password() {
'should create new password when no passwords exists' => array(
'args' => array( 'name' => 'test3' ),
),
- 'should create new password when name is unique' => array(
+ 'should create new password when name is unique' => array(
'args' => array( 'name' => 'test3' ),
'names' => array( 'test1', 'test2' ),
),
+ 'should create new password with expiration' => array(
+ 'args' => array(
+ 'name' => 'test_expire',
+ 'expires' => time() + DAY_IN_SECONDS,
+ ),
+ ),
);
}
@@ -154,7 +160,7 @@ public function test_update_application_password( array $update, array $existing
// Check updated only given values.
$updated_item = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid );
foreach ( $updated_item as $key => $update_value ) {
- $expected_value = $update[ $key ] ?? $original_item[ $key ];
+ $expected_value = array_key_exists( $key, $update ) ? $update[ $key ] : $original_item[ $key ];
$this->assertSame( $expected_value, $update_value );
}
}
@@ -186,6 +192,17 @@ public function data_update_application_password() {
'update' => array( 'name' => 'Test Updated' ),
'existing' => array( 'name' => 'Test' ),
),
+ 'should update expires' => array(
+ 'update' => array( 'expires' => time() + DAY_IN_SECONDS ),
+ 'existing' => array( 'name' => 'Test' ),
+ ),
+ 'should clear expires' => array(
+ 'update' => array( 'expires' => null ),
+ 'existing' => array(
+ 'name' => 'Test',
+ 'expires' => time() + DAY_IN_SECONDS,
+ ),
+ ),
);
}
diff --git a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php
index 060a5c0912a94..287d649e2ba19 100644
--- a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php
@@ -906,6 +906,7 @@ protected function check_response( $response, $item, $password = false ) {
$this->assertArrayHasKey( 'created', $response );
$this->assertArrayHasKey( 'last_used', $response );
$this->assertArrayHasKey( 'last_ip', $response );
+ $this->assertArrayHasKey( 'expires', $response );
$this->assertSame( $item['uuid'], $response['uuid'] );
$this->assertSame( $item['app_id'], $response['app_id'] );
@@ -924,6 +925,12 @@ protected function check_response( $response, $item, $password = false ) {
$this->assertNull( $response['last_ip'] );
}
+ if ( ! empty( $item['expires'] ) ) {
+ $this->assertSame( gmdate( 'Y-m-d\TH:i:s', $item['expires'] ), $response['expires'] );
+ } else {
+ $this->assertNull( $response['expires'] );
+ }
+
if ( $password ) {
$this->assertArrayHasKey( 'password', $response );
} else {
@@ -947,7 +954,8 @@ public function test_get_item_schema() {
$this->assertArrayHasKey( 'created', $properties );
$this->assertArrayHasKey( 'last_used', $properties );
$this->assertArrayHasKey( 'last_ip', $properties );
- $this->assertCount( 7, $properties );
+ $this->assertArrayHasKey( 'expires', $properties );
+ $this->assertCount( 8, $properties );
}
/**
diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js
index 003dc397ae305..6dc369c366f5c 100644
--- a/tests/qunit/fixtures/wp-api-generated.js
+++ b/tests/qunit/fixtures/wp-api-generated.js
@@ -10200,6 +10200,15 @@ mockedApiResponse.Schema = {
"minLength": 1,
"pattern": ".*\\S.*",
"required": true
+ },
+ "expires": {
+ "description": "The GMT date the application password expires.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time",
+ "required": false
}
}
},
@@ -10295,6 +10304,15 @@ mockedApiResponse.Schema = {
"minLength": 1,
"pattern": ".*\\S.*",
"required": false
+ },
+ "expires": {
+ "description": "The GMT date the application password expires.",
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time",
+ "required": false
}
}
},