From 6e2b4c3cc63d364933f6da03e80e0487c1d11edd Mon Sep 17 00:00:00 2001
From: b12f <git@benjaminbaedorf.eu>
Date: Sat, 1 Feb 2025 03:44:43 +0100
Subject: [PATCH] feat: add auto-delete-accounts

---
 flake.lock                                    |  8 +-
 .../keycloak/auto-delete-accounts/.gitignore  |  1 +
 .../auto-delete-accounts.mjs                  | 75 +++++++++++++++++++
 .../auto-delete-accounts/package-lock.json    | 26 +++++++
 .../auto-delete-accounts/package.json         | 14 ++++
 .../keycloak/automated-account-deletion.nix   | 10 +++
 tests/keycloak.nix                            | 11 +++
 7 files changed, 141 insertions(+), 4 deletions(-)
 create mode 100644 modules/keycloak/auto-delete-accounts/.gitignore
 create mode 100644 modules/keycloak/auto-delete-accounts/auto-delete-accounts.mjs
 create mode 100644 modules/keycloak/auto-delete-accounts/package-lock.json
 create mode 100644 modules/keycloak/auto-delete-accounts/package.json
 create mode 100644 modules/keycloak/automated-account-deletion.nix

diff --git a/flake.lock b/flake.lock
index f2c2514..bb217bf 100644
--- a/flake.lock
+++ b/flake.lock
@@ -335,11 +335,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1737819581,
-        "narHash": "sha256-i9rZSxy33BlDpp4JY9SI2zEFo5EhMnS7cAhqHAPRUZA=",
+        "lastModified": 1738364013,
+        "narHash": "sha256-GAg9RaSThTW8+nL/rTsb12i4EHWl5uBqtq8Sp2jEoVg=",
         "ref": "main",
-        "rev": "bb9c6f3e3608f0d342ab74d921caddfe4a8bf5d6",
-        "revCount": 7,
+        "rev": "1b6c4dda9361bcc108a283e2c38867983474da17",
+        "revCount": 8,
         "type": "git",
         "url": "https://git.pub.solar/pub-solar/keycloak-event-listener"
       },
diff --git a/modules/keycloak/auto-delete-accounts/.gitignore b/modules/keycloak/auto-delete-accounts/.gitignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/modules/keycloak/auto-delete-accounts/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/modules/keycloak/auto-delete-accounts/auto-delete-accounts.mjs b/modules/keycloak/auto-delete-accounts/auto-delete-accounts.mjs
new file mode 100644
index 0000000..a2655bd
--- /dev/null
+++ b/modules/keycloak/auto-delete-accounts/auto-delete-accounts.mjs
@@ -0,0 +1,75 @@
+#!/usr/bin/env zx
+
+import { add, sub, isEqual, isAfter } from "date-fns";
+
+const realm = argv.realm;
+const clientId = argv.clientId;
+const server = argv.server;
+
+/* 
+ * You'll have to set KC_CLI_CLIENT_SECRET
+ */
+
+let users = JSON.parse(await $`kcadm.sh get users -r ${realm} --server ${server} --client ${clientId} --no-config"`);
+
+// Set a last-login value to today for any accounts that do not have one
+
+const noLastLogin = users.filter(user => !user.attributes?.["last-login"]?.length);
+
+const now = new Date();
+const todayString = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getDate()}`;
+await Promise.all(noLastLogin.map((user) => {
+  const attributes = {
+    "last-login": [todayString],
+    ...user.attributes,
+  };
+  $`kcadm.sh update users/${user.id} -s 'attributes=${JSON.stringify(attributes)}' -r ${realm} --server ${server} --client ${clientId} --no-config"`;
+}));
+
+users = JSON.parse(await $`kcadm.sh get users -r ${realm} --server ${server} --client ${clientId} --no-config"`);
+
+// Handle non-validated users
+
+const nonValidated = users.filter(user => !user.emailVerified);
+
+await Promise.all(nonValidated.map((user) => {
+  const lastLogin = new Date(user.attributes?.["last-login"]);
+
+  const deletionDate = add(lastLogin, { months: 1 });
+
+  if (isEqual(now, sub(deletionDate, { weeks: 1 })) {
+    // send reminder
+  }
+
+  if (isEqual(now, sub(deletionDate, { days: 1 })) {
+    // send reminder
+  }
+
+  if (isAfter(now, deletionDate)) {
+    // delete
+  }
+}).filter(n => !!n));
+
+const validated = users.filter(user => user.emailVerified);
+
+await Promise.all(validated.map((user) => {
+  const lastLogin = new Date(user.attributes?.["last-login"]);
+
+  const deletionDate = add(lastLogin, { years: 2 });
+
+  if (isEqual(now, sub(deletionDate, { months: 1 })) {
+    // Send reminder to validated accounts that have not logged in for 2 years - 1 month
+  }
+
+  if (isEqual(now, sub(deletionDate, { weeks: 1 })) {
+    // Send reminder to validated accounts that have not logged in for 2 years - 1 week
+  }
+
+  if (isEqual(now, sub(deletionDate, { days: 1 })) {
+    // Send reminder to validated accounts that have not logged in for 2 years - 1 day
+  }
+
+  if (isEqual(now, deletionDate)) {
+    // Delete validated that have not logged in for more than 2 years
+  }
+}).filter(n => !!n));
diff --git a/modules/keycloak/auto-delete-accounts/package-lock.json b/modules/keycloak/auto-delete-accounts/package-lock.json
new file mode 100644
index 0000000..fc87a95
--- /dev/null
+++ b/modules/keycloak/auto-delete-accounts/package-lock.json
@@ -0,0 +1,26 @@
+{
+  "name": "auto-delete-accounts",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "auto-delete-accounts",
+      "version": "1.0.0",
+      "license": "AGPL-3.0-or-later",
+      "dependencies": {
+        "date-fns": "^4.1.0"
+      }
+    },
+    "node_modules/date-fns": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+      "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    }
+  }
+}
diff --git a/modules/keycloak/auto-delete-accounts/package.json b/modules/keycloak/auto-delete-accounts/package.json
new file mode 100644
index 0000000..f199b56
--- /dev/null
+++ b/modules/keycloak/auto-delete-accounts/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "auto-delete-accounts",
+  "version": "1.0.0",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "author": "",
+  "license": "AGPL-3.0-or-later",
+  "description": "",
+  "dependencies": {
+    "date-fns": "^4.1.0"
+  }
+}
diff --git a/modules/keycloak/automated-account-deletion.nix b/modules/keycloak/automated-account-deletion.nix
new file mode 100644
index 0000000..68544a5
--- /dev/null
+++ b/modules/keycloak/automated-account-deletion.nix
@@ -0,0 +1,10 @@
+{
+  writeShellScriptBin,
+  keycloak,
+  jq
+}:
+writeShellScriptBin "autodelete-accounts" ''
+  set -e
+
+  USERS=$(${keycloak}/bin/kcadm.sh get users -r test.pub.solar --server http://localhost:8080 --realm master --user admin --password password --no-config")
+''
diff --git a/tests/keycloak.nix b/tests/keycloak.nix
index 3148f98..daef05f 100644
--- a/tests/keycloak.nix
+++ b/tests/keycloak.nix
@@ -181,6 +181,9 @@ in
       puppeteer_succeed('page.waitForNetworkIdle()')
       client.screenshot("logged-out")
 
+      auth_server.wait_for_file('/tmp/continue', 3600)
+      auth_server.execute('rm /tmp/continue')
+
       puppeteer_succeed('page.locator("[name=username]").fill("test-user")')
       puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
       puppeteer_succeed('page.locator("[name=password]").fill("Password1234")')
@@ -204,6 +207,9 @@ in
       puppeteer_succeed('page.waitForNetworkIdle()')
       client.screenshot("TOTP-signed-in")
 
+      auth_server.wait_for_file('/tmp/continue', 600)
+      auth_server.execute('rm /tmp/continue')
+
       ####### Delete TOTP #######
 
       puppeteer_scroll_into_view('[data-testid="otp/credential-list"]')
@@ -212,5 +218,10 @@ in
 
       # puppeteer_succeed('page.locator(`[data-testid="otp/credential-list"] button::-p-text(Delete)`).click()')
       # client.screenshot("TOTP-deleted")
+
+      ####### Automated account deletion #######
+
+      auth_server.succeed("${pkgs.keycloak}/bin/kcadm.sh get users -r test.pub.solar --server http://localhost:8080 --realm master --user admin --password password --no-config")
+
     '';
 }