[OIDC] allow user deletion for accounts without a password (#2567)

* fix: user deletion for OIDC accounts

* clean(backend): update copied license header

* clean(backend): replace previously removed comments

* fix: double-encoding
This commit is contained in:
Eric Dubé
2026-02-27 18:55:12 -05:00
committed by GitHub
parent 9d4e990b92
commit 2cc8cb22f8
6 changed files with 179 additions and 106 deletions
@@ -106,6 +106,8 @@ const hardcoded_user_group_permissions = {
'local-terminal:access': {},
},
'b7220104-7905-4985-b996-649fdcdb3c8f': {
'driver': {},
'service': {},
'service:hello-world:ii:hello-world': policy_perm('temp.es'),
'service:puter-kvstore:ii:puter-kvstore': policy_perm('temp.kv'),
'driver:puter-kvstore': policy_perm('temp.kv'),
@@ -119,6 +121,8 @@ const hardcoded_user_group_permissions = {
'service:es\\Csubdomain:ii:crud-q': policy_perm('user.es'),
},
'78b1b1dd-c959-44d2-b02c-8735671f9997': {
'driver': {},
'service': {},
'service:hello-world:ii:hello-world': policy_perm('user.es'),
'service:puter-kvstore:ii:puter-kvstore': policy_perm('user.kv'),
'driver:puter-kvstore': policy_perm('user.kv'),
@@ -1,61 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const eggspress = require('../../api/eggspress');
const { deleteUser, invalidate_cached_user } = require('../../helpers');
const config = require('../../config');
module.exports = eggspress('/delete-own-user', {
subdomain: 'api',
auth: true,
allowedMethods: ['POST'],
}, async (req, res) => {
const bcrypt = require('bcrypt');
const validate_request = async () => {
const user = req.user;
// `user` should always have a value, but this is checked
// any way in case the auth middleware is broken.
if ( ! user ) return false;
// temporary users don't require password verification
if ( !user.email && !user.password ) {
return true;
}
if ( ! req.body.password ) return false;
if ( !user || !user.password ) return false;
if ( ! await bcrypt.compare(req.body.password, req.user.password) ) {
return false;
}
return true;
};
if ( ! await validate_request() ) {
return res.status(400).send({ success: false });
}
res.clearCookie(config.cookie_name);
await deleteUser(req.user.id);
invalidate_cached_user(req.user);
return res.send({ success: true });
});
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2026-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const config = require('../../config');
const { deleteUser, invalidate_cached_user } = require('../../helpers');
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
module.exports = {
route: '/delete-own-user',
methods: ['POST'],
handler: async (req, res) => {
res.clearCookie(config.cookie_name);
res.clearCookie(REVALIDATION_COOKIE_NAME);
await deleteUser(req.user.id);
invalidate_cached_user(req.user);
return res.send({ success: true });
},
};
@@ -53,7 +53,6 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/auth/app-uid-from-origin'));
app.use(require('../routers/auth/create-access-token'));
app.use(require('../routers/auth/revoke-access-token'));
app.use(require('../routers/auth/delete-own-user'));
app.use(require('../routers/auth/configure-2fa'));
app.use(require('../routers/drivers/call'));
app.use(require('../routers/drivers/list-interfaces'));
@@ -109,9 +109,10 @@ class UserProtectedEndpointsService extends BaseService {
next();
});
// Do not allow temporary users
// Do not allow temporary users (except for delete-own-user, which allows them)
router.use(async (req, res, next) => {
if ( req.method === 'OPTIONS' ) return next();
if ( req.path === '/delete-own-user' ) return next();
if ( req.user.password === null && req.user.email === null ) {
return APIError.create('temporary_account').write(res);
@@ -122,6 +123,7 @@ class UserProtectedEndpointsService extends BaseService {
/**
* Middleware to validate identity: either password (bcrypt) or a valid OIDC revalidation cookie.
* OIDC-only accounts (user.password === null) must use revalidation; password accounts may use either.
* Temporary users (no password, no email) are allowed only for delete-own-user.
*/
router.use(async (req, res, next) => {
if ( req.method === 'OPTIONS' ) return next();
@@ -129,6 +131,10 @@ class UserProtectedEndpointsService extends BaseService {
const user = await get_user({ id: req.user.id, force: true });
const revalidationCookie = req.cookies && req.cookies[REVALIDATION_COOKIE_NAME];
if ( user.password === null && user.email === null ) {
return next();
}
if ( req.body.password ) {
if ( user.password === null ) {
return (APIError.create('oidc_revalidation_required', null, await this.#revalidateUrlFields(req, user))).write(res);
@@ -168,6 +174,8 @@ class UserProtectedEndpointsService extends BaseService {
Endpoint(require('../../routers/user-protected/change-username.js')).attach(router);
Endpoint(require('../../routers/user-protected/disable-2fa.js')).attach(router);
Endpoint(require('../../routers/user-protected/delete-own-user.js')).attach(router);
}
}
@@ -17,11 +17,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { openRevalidatePopup } from '../../util/openid.js';
import UIWindow from '../UIWindow.js';
async function UIWindowFinalizeUserDeletion (options) {
return new Promise(async (resolve) => {
options = options ?? {};
const oidc_only = !!(window.user && window.user.oidc_only);
let h = '';
@@ -39,6 +41,22 @@ async function UIWindowFinalizeUserDeletion (options) {
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
h += '</div>';
}
// OIDC-only: revalidate via popup (no password)
else if ( oidc_only ) {
h += '<div style="padding: 20px;">';
h += '<div class="generic-close-window-button disable-user-select"> &times; </div>';
h += `<img src="${window.icons['danger.svg']}" class="account-deletion-confirmation-icon">`;
h += `<p class="account-deletion-confirmation-prompt">${i18n('confirm_delete_user')}</p>`;
h += '<div class="delete-oidc-wrap" style="margin-top:10px;">';
h += '<p class="delete-oidc-flow-notice" style="margin:0;font-size:12px;color:#666;"></p>';
h += '<span class="delete-revalidated-msg" style="display:none;"></span>';
h += '</div>';
h += '<p class="delete-oidc-hint" style="margin-top:6px;font-size:12px;color:#666;display:none;"></p>';
h += '<div class="error-message"></div>';
h += `<button class="button button-block button-danger proceed-with-user-deletion">${i18n('delete_account')}</button>`;
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
h += '</div>';
}
// otherwise ask for password
else {
h += '<div style="padding: 20px;">';
@@ -72,6 +90,12 @@ async function UIWindowFinalizeUserDeletion (options) {
allow_user_select: true,
backdrop: true,
onAppend: function (el_window) {
if ( oidc_only ) {
$(el_window).find('.delete-oidc-flow-notice').text(
i18n('revalidate_flow_notice') ||
'You will be asked to sign in with your linked account when you continue.',
);
}
},
width: 500,
dominant: false,
@@ -91,7 +115,25 @@ async function UIWindowFinalizeUserDeletion (options) {
$(el_window).close();
});
$(el_window).find('.proceed-with-user-deletion').on('click', function () {
const origin = window.gui_origin || window.api_origin || '';
const apiUrl = `${origin}/user-protected/delete-own-user`;
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
let revalidated = false;
const doDeleteRequest = async (body = {}) => {
return fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
};
const showError = (msg) => {
$(el_window).find('.error-message').html(html_encode(msg)).show();
};
$(el_window).find('.proceed-with-user-deletion').on('click', async function () {
$(el_window).find('.error-message').hide();
// if user is temporary, check if they typed 'confirm'
if ( window.user.is_temp ) {
@@ -99,55 +141,100 @@ async function UIWindowFinalizeUserDeletion (options) {
// user must type 'confirm' or the translation of 'confirm' to delete their account
if ( confirm !== 'confirm' && confirm !== i18n('confirm').toLowerCase() ) {
$(el_window).find('.error-message').html(i18n('type_confirm_to_delete_account'), false);
$(el_window).find('.error-message').show();
showError(i18n('type_confirm_to_delete_account', [], false));
return;
}
}
// otherwise, check if password is correct
else {
if ( $(el_window).find('.confirm-user-deletion-password').val() === '' ) {
$(el_window).find('.error-message').html(i18n('all_fields_required'), false);
$(el_window).find('.error-message').show();
} else if ( oidc_only && !revalidated ) {
$(el_window).find('.proceed-with-user-deletion').addClass('disabled');
$(el_window).find('.delete-oidc-hint').text(REVALIDATE_POPUP_TEXT).show();
try {
const revalidateUrl = window.user && window.user.oidc_revalidate_url;
await openRevalidatePopup(revalidateUrl);
} catch (e) {
showError(e.message || 'Authentication failed');
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
$(el_window).find('.delete-oidc-hint').hide();
return;
}
$(el_window).find('.delete-oidc-hint').hide();
$(el_window).find('.delete-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
revalidated = true;
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
const res = await doDeleteRequest({});
const data = await res.json().catch(() => ({}));
if ( res.status === 401 ) {
window.logout(); return;
}
if ( res.ok && data.success ) {
window.user.deleted = true; window.logout(); return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
try {
await openRevalidatePopup(data.revalidate_url);
} catch (e) {
showError(e.message || 'Authentication failed');
return;
}
const retry = await doDeleteRequest({});
const retryData = await retry.json().catch(() => ({}));
if ( retry.ok && retryData.success ) {
window.user.deleted = true; window.logout(); return;
}
showError(retryData.message || 'Request failed');
return;
}
showError(data.message || 'Request failed');
return;
} else if ( !window.user.is_temp && !oidc_only ) {
const password = $(el_window).find('.confirm-user-deletion-password').val();
if ( password === '' ) {
showError(i18n('all_fields_required', [], false));
return;
}
}
// delete user
$.ajax({
url: `${window.api_origin }/delete-own-user`,
type: 'POST',
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${ window.auth_token}`,
},
data: JSON.stringify({
password: $(el_window).find('.confirm-user-deletion-password').val(),
}),
statusCode: {
401: function () {
window.logout();
},
400: function () {
$(el_window).find('.error-message').html(i18n('incorrect_password'));
$(el_window).find('.error-message').show();
},
},
success: function (data) {
if ( data.success ) {
// mark user as deleted
window.user.deleted = true;
// log user out
window.logout();
}
else {
$(el_window).find('.error-message').html(html_encode(data.error));
$(el_window).find('.error-message').show();
let res = await doDeleteRequest(
window.user.is_temp ? {} : { password: $(el_window).find('.confirm-user-deletion-password').val() || undefined },
);
const data = await res.json().catch(() => ({}));
}
},
});
if ( res.status === 401 ) {
window.logout();
return;
}
if ( res.ok && data.success ) {
window.user.deleted = true;
window.logout();
return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
$(el_window).find('.proceed-with-user-deletion').addClass('disabled');
$(el_window).find('.delete-oidc-hint').text(REVALIDATE_POPUP_TEXT).show();
try {
await openRevalidatePopup(data.revalidate_url);
} catch (e) {
showError(e.message || 'Authentication failed');
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
$(el_window).find('.delete-oidc-hint').hide();
return;
}
$(el_window).find('.delete-oidc-hint').hide();
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
const retry = await doDeleteRequest({});
const retryData = await retry.json().catch(() => ({}));
if ( retry.ok && retryData.success ) {
window.user.deleted = true;
window.logout();
return;
}
showError(retryData.message || 'Request failed');
return;
}
if ( res.status === 403 && data.code === 'session_required' ) {
showError(data.message || i18n('session_required', [], false) || 'This action requires a full session.');
return;
}
showError(data.message || 'Request failed');
});
});
}