diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 371f06459..038b983a6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,7 +4,7 @@ on:
   push:
 
 env:
-  go-version: '1.20'
+  go-version: '1.25'
   node-version: 8
   artifacts-retention-days: 5
 
diff --git a/deploy/ansible/roles/icarodb/files/icaro.sql b/deploy/ansible/roles/icarodb/files/icaro.sql
index e02dbf371..dd7052b08 100644
--- a/deploy/ansible/roles/icarodb/files/icaro.sql
+++ b/deploy/ansible/roles/icarodb/files/icaro.sql
@@ -3,6 +3,7 @@ CREATE TABLE `accounts` (
   `id` serial,
   `creator_id` bigint unsigned NOT NULL,
   `uuid` varchar(200),
+  `logto_org_id` varchar(64) DEFAULT NULL,
   `type` varchar(200) NOT NULL,
   `name` varchar(200) NOT NULL,
   `username` varchar (200) NOT NULL,
@@ -17,6 +18,7 @@ CREATE TABLE `accounts` (
   `created` datetime,
   UNIQUE KEY (`username`),
   UNIQUE KEY (`uuid`),
+  UNIQUE KEY (`logto_org_id`),
   KEY(`username`),
   KEY(`uuid`),
   PRIMARY KEY(`id`)
diff --git a/deploy/ansible/roles/sun/templates/sun-api-conf.j2 b/deploy/ansible/roles/sun/templates/sun-api-conf.j2
index 4c486517d..d2419ab2a 100644
--- a/deploy/ansible/roles/sun/templates/sun-api-conf.j2
+++ b/deploy/ansible/roles/sun/templates/sun-api-conf.j2
@@ -9,7 +9,7 @@
     "token_expires_days": 1,
     "cors": {
         "origins": ["https://{{ icaro.hostname }}"],
-        "headers": ["Token", "Content-Type"],
+        "headers": ["Token", "Content-Type", "Authorization"],
         "methods": ["GET", "PUT", "POST", "DELETE"]
     },
     "captive_portal": {
@@ -103,5 +103,22 @@ icaro.sun_api.email_smtp_password is defined %}
         "verb": "POST",
         "endpoint": "/api/users"
     }]
+    },
+    "oidc": {
+        "issuer": "{{ icaro.sun_api.oidc_issuer | default('') }}",
+        "client_id": "{{ icaro.sun_api.oidc_client_id | default('') }}",
+        "client_secret": "{{ icaro.sun_api.oidc_client_secret | default('') }}",
+        "redirect_uri": "{{ icaro.sun_api.oidc_redirect_uri | default('http://localhost:8080/api/auth/oidc/callback') }}",
+        "frontend_url": "{{ icaro.sun_api.oidc_frontend_url | default('http://localhost:8081') }}",
+        "scopes": {{ icaro.sun_api.oidc_scopes | default(['openid', 'profile', 'email', 'roles', 'urn:logto:scope:organizations', 'urn:logto:scope:organization_roles']) | to_json }},
+        "role_mapping": {{ icaro.sun_api.oidc_role_mapping | default(['super admin:admin']) | to_json }},
+        "org_admin_role": "{{ icaro.sun_api.oidc_org_admin_role | default('Admin') }}",
+        "org_reseller_role": "{{ icaro.sun_api.oidc_org_reseller_role | default('Reseller') }}",
+        "my_api_url": "{{ icaro.sun_api.oidc_my_api_url | default('') }}",
+        "my_api_key": "{{ icaro.sun_api.oidc_my_api_key | default('') }}",
+        "default_subscription_plan_id": {{ icaro.sun_api.oidc_default_subscription_plan_id | default(4) }},
+        "userinfo_audience": "{{ icaro.sun_api.oidc_userinfo_audience | default('') }}",
+        "userinfo_origins": {{ icaro.sun_api.oidc_userinfo_origins | default(['https://my.nethesis.it', 'https://beta.my.nethesis.it']) | to_json }},
+        "show_login_button": {{ icaro.sun_api.oidc_show_login_button | default(true) | lower }}
     }
 }
diff --git a/go.mod b/go.mod
index 8488c04ea..a3c6e0358 100644
--- a/go.mod
+++ b/go.mod
@@ -1,17 +1,43 @@
 module github.com/nethesis/icaro
 
-go 1.15
+go 1.25.0
 
 require (
 	github.com/appleboy/gofight/v2 v2.1.2
 	github.com/avct/uasurfer v0.0.0-20180817072212-dc0ec4fd1e87
+	github.com/coreos/go-oidc/v3 v3.19.0
 	github.com/fatih/structs v1.1.0
 	github.com/gin-contrib/cors v1.3.0
 	github.com/gin-gonic/gin v1.7.7
 	github.com/jinzhu/gorm v1.9.8
 	github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
 	github.com/satori/go.uuid v1.2.1-0.20180103174451-36e9d2ebbde5
-	github.com/stretchr/testify v1.4.0
-	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+	github.com/stretchr/testify v1.7.0
+	golang.org/x/oauth2 v0.36.0
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
 )
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+	github.com/go-playground/locales v0.13.0 // indirect
+	github.com/go-playground/universal-translator v0.17.0 // indirect
+	github.com/go-playground/validator/v10 v10.4.1 // indirect
+	github.com/go-sql-driver/mysql v1.4.1 // indirect
+	github.com/golang/protobuf v1.3.3 // indirect
+	github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect
+	github.com/json-iterator/go v1.1.9 // indirect
+	github.com/leodido/go-urn v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.12 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/ugorji/go/codec v1.1.7 // indirect
+	golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
+	golang.org/x/sys v0.0.0-20200116001909-b77594299b42 // indirect
+	google.golang.org/appengine v1.4.0 // indirect
+	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+	gopkg.in/yaml.v2 v2.2.8 // indirect
+	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
+)
diff --git a/go.sum b/go.sum
index 679fd1c69..50a138834 100644
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,8 @@ github.com/avct/uasurfer v0.0.0-20180817072212-dc0ec4fd1e87 h1:TUuuMI5Sxsw5IVVu3
 github.com/avct/uasurfer v0.0.0-20180817072212-dc0ec4fd1e87/go.mod h1:noBAuukeYOXa0aXGqxr24tADqkwDO2KRD15FsuaZ5a8=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
+github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -35,6 +37,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
 github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
 github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
 github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
@@ -59,7 +63,6 @@ github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I
 github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -130,10 +133,10 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
 github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
 github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
 github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
@@ -159,6 +162,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -208,6 +213,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 000000000..c60d3b3a1
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,144 @@
+# OIDC organization bootstrap
+
+`oidc_org_bootstrap.py` links existing Icaro **reseller** accounts to their
+My (Logto) organization by generating the `UPDATE` statements that populate
+`accounts.logto_org_id`. That column is the **only** link the OIDC login
+uses at runtime: no email/VAT matching ever happens during login. This
+script is where the (reviewed, one-time) matching happens instead.
+
+Identity chain used for matching:
+
+```
+icaro accounts.uuid == old-my (noc) reseller.uuid
+noc reseller --(uuid / piva / normalized company name)--> new-my reseller org
+```
+
+The script is **offline** (CSV in, SQL + reports out — it never touches a
+database) and **idempotent**: every generated `UPDATE` carries
+`AND logto_org_id IS NULL`, so re-running and re-applying only adds new
+links and never rewrites existing ones.
+
+## Prerequisites
+
+1. The `accounts.logto_org_id` column must exist (new installs get it from
+   `deploy/ansible/roles/icarodb/files/icaro.sql`; existing databases need):
+
+   ```sql
+   ALTER TABLE accounts
+     ADD COLUMN logto_org_id varchar(64) DEFAULT NULL AFTER uuid,
+     ADD UNIQUE KEY logto_org_id (logto_org_id);
+   ```
+
+2. Read access to the three data sources: the Icaro MariaDB, the old My
+   (noc) MariaDB, and the new My PostgreSQL.
+
+## Step 1 — extract the input files
+
+Both TSV (`mysql -B` output) and CSV are accepted; the delimiter is
+auto-detected. Keep the headers exactly as below.
+
+**Icaro accounts** (only `type='reseller'`; desk/customer accounts are
+internal to Icaro and must never be linked):
+
+```bash
+mysql -B icaro -e "SELECT id, IFNULL(uuid,'') AS uuid, username, IFNULL(email,'') AS email
+                   FROM accounts WHERE type='reseller';" > icaro_accounts.tsv
+```
+
+**Old My resellers** (noc):
+
+```bash
+mysql -B noc -e "SELECT r.uuid, IFNULL(r.piva,'') AS piva, IFNULL(r.company,'') AS company,
+                        u.username, u.email
+                 FROM reseller r JOIN User u ON u.id = r.user_id
+                 WHERE r.deleted IS NULL;" > noc_resellers.tsv
+```
+
+**New My reseller organizations** (PostgreSQL; use `psql` so the JSON
+`custom_data` is quoted correctly):
+
+```bash
+psql "$DATABASE_URL" -c "\copy (SELECT COALESCE(logto_id,'') AS logto_id, name,
+                                COALESCE(custom_data::text,'{}') AS custom_data
+                                FROM resellers WHERE deleted_at IS NULL)
+                         TO 'my_orgs.csv' WITH CSV HEADER"
+```
+
+## Step 2 — dry run
+
+```bash
+python3 scripts/oidc_org_bootstrap.py \
+  --icaro-accounts icaro_accounts.tsv \
+  --noc-resellers  noc_resellers.tsv \
+  --my-orgs        my_orgs.csv \
+  --output-dir     bootstrap-out
+```
+
+Nothing is written to any database. Output files:
+
+| File | Content | Action |
+|---|---|---|
+| `matched.sql` | one `UPDATE` per linked account, with account → org and match method (`uuid`/`piva`/`name`) as a comment | review, then apply |
+| `report_matched.csv` | same matches in tabular form | review |
+| `report_icaro_only.csv` | accounts with no old-My reseller (self-registered / Icaro-only) | none: they keep the classic password login |
+| `report_no_my_org.csv` | old-My resellers whose organization is not on the new My yet | re-run after the next org import batch |
+| `report_ambiguous.csv` | multiple candidate orgs, or one org claimed by multiple accounts | resolve by hand |
+
+## Step 3 — review and apply
+
+Read `matched.sql` (especially any `name`-method matches, which are the
+loosest) and the ambiguous report, then:
+
+```bash
+mysql icaro < bootstrap-out/matched.sql
+```
+
+## Re-running (important)
+
+Run steps 1–3 again **after every batch of organization imports on the new
+My**. If a reseller's organization exists on My but the bootstrap has not
+linked it yet, their first OIDC login triggers JIT provisioning and creates
+a *new empty* account instead of linking the historical one (runtime
+deliberately performs no heuristic matching). Keeping import and bootstrap
+close together minimizes that window; the section below covers the cleanup
+when a duplicate slips through.
+
+Apply with `--force` so one conflict does not stop the rest, and read the
+errors — they are the duplicate detector:
+
+```bash
+mysql --force icaro < bootstrap-out/matched.sql
+```
+
+A `Duplicate entry ... for key 'logto_org_id'` error means that org was
+already claimed by a JIT-created duplicate: reconcile it as below.
+
+## Reconciling a JIT duplicate
+
+Symptom: a reseller logged in via My before the bootstrap linked their
+historical account. JIT created a fresh empty account (recognizable:
+`username = logto_org_id`, journal line `OIDC provisioning: created company
+account ...`), while the historical account still has the hotspots.
+
+```sql
+-- 0. find the two accounts
+SELECT id, username, name, logto_org_id, created FROM accounts
+ WHERE logto_org_id = '<org_id>' OR (name LIKE '%<company>%' AND type = 'reseller');
+
+-- 1. make sure the duplicate is empty (otherwise it needs a manual merge)
+SELECT COUNT(*) FROM hotspots WHERE account_id = <dup_id>;
+
+-- 2. move the link to the historical account
+UPDATE accounts SET logto_org_id = NULL WHERE id = <dup_id>;
+UPDATE accounts SET logto_org_id = '<org_id>' WHERE id = <legacy_id> AND logto_org_id IS NULL;
+
+-- 3. drop the empty duplicate
+DELETE FROM access_tokens      WHERE account_id = <dup_id>;
+DELETE FROM subscriptions      WHERE account_id = <dup_id>;
+DELETE FROM account_sms_counts WHERE account_id = <dup_id>;
+DELETE FROM accounts           WHERE id = <dup_id>;
+```
+
+The reseller's next login lands on the historical account. If step 1 shows
+hotspots in the duplicate, do **not** delete it: move its hotspots to the
+historical account first (or escalate — that is a merge, not a cleanup).
diff --git a/scripts/oidc_org_bootstrap.py b/scripts/oidc_org_bootstrap.py
new file mode 100644
index 000000000..2e2b674b5
--- /dev/null
+++ b/scripts/oidc_org_bootstrap.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+"""
+One-shot bootstrap: link existing Icaro company accounts to My (Logto)
+organizations by writing accounts.logto_org_id.
+
+Identity chain (no email heuristics at runtime):
+
+    icaro accounts.uuid == old-my (noc) reseller.uuid
+    noc reseller --(uuid / piva / company name)--> new-my reseller org
+
+Inputs (CSV with header):
+
+  --icaro-accounts  id,uuid,username,email
+      mysql icaro:  SELECT id, uuid, username, email FROM accounts
+                    WHERE type='reseller' AND logto_org_id IS NULL;
+
+  --noc-resellers   uuid,piva,company,username,email
+      mysql noc:    SELECT r.uuid, r.piva, r.company, u.username, u.email
+                    FROM reseller r JOIN User u ON u.id = r.user_id
+                    WHERE r.deleted IS NULL;
+
+  --my-orgs         logto_id,name,custom_data
+      psql my:      SELECT logto_id, name, custom_data FROM resellers
+                    WHERE deleted_at IS NULL;
+      (custom_data as JSON text)
+
+Outputs (in --output-dir, default ./bootstrap-out):
+
+  matched.sql             UPDATE statements to apply (review first!)
+  report_matched.csv      account -> org with match method
+  report_icaro_only.csv   nethspot-only accounts (no noc reseller): keep classic login
+  report_no_my_org.csv    noc reseller found, but no org on new my
+  report_ambiguous.csv    multiple candidate orgs, resolve by hand
+
+Nothing is written to any database: apply matched.sql manually.
+"""
+
+import argparse
+import csv
+import json
+import os
+import re
+import sys
+from collections import defaultdict
+
+
+def norm_name(s):
+    """Normalize a company name for loose comparison."""
+    s = (s or "").lower()
+    s = re.sub(r"\b(s\.?r\.?l\.?s?|s\.?p\.?a\.?|s\.?n\.?c\.?|s\.?a\.?s\.?|srls|unipersonale)\b", "", s)
+    return re.sub(r"[^a-z0-9]", "", s)
+
+
+def load_csv(path):
+    """Load a CSV or TSV file (mysql -B output works as-is)."""
+    with open(path, newline="", encoding="utf-8", errors="replace") as f:
+        header = f.readline()
+        delimiter = "\t" if "\t" in header else ","
+        f.seek(0)
+        return list(csv.DictReader(f, delimiter=delimiter))
+
+
+def main():
+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("--icaro-accounts", required=True)
+    ap.add_argument("--noc-resellers", required=True)
+    ap.add_argument("--my-orgs", required=True)
+    ap.add_argument("--output-dir", default="bootstrap-out")
+    args = ap.parse_args()
+
+    accounts = load_csv(args.icaro_accounts)
+    noc = load_csv(args.noc_resellers)
+    orgs = load_csv(args.my_orgs)
+
+    noc_by_uuid = {r["uuid"].strip(): r for r in noc if r.get("uuid", "").strip()}
+
+    # Index my orgs by the legacy keys found in custom_data (raw scan: the
+    # exact key names vary between migration batches) and by normalized name
+    orgs_by_token = defaultdict(list)   # token (uuid/piva found in custom_data) -> orgs
+    orgs_by_name = defaultdict(list)
+    for org in orgs:
+        raw = (org.get("custom_data") or "").lower()
+        try:
+            cd = json.loads(org.get("custom_data") or "{}")
+        except ValueError:
+            cd = {}
+        tokens = set(re.findall(r"[0-9a-f]{8}-[0-9a-f-]{10,}[0-9a-f]|\b\d{9,11}\b", raw))
+        for v in cd.values():
+            if isinstance(v, str) and v.strip():
+                tokens.add(v.strip().lower())
+        for t in tokens:
+            orgs_by_token[t].append(org)
+        orgs_by_name[norm_name(org.get("name"))].append(org)
+
+    matched, icaro_only, no_my_org, ambiguous = [], [], [], []
+    used_orgs = defaultdict(list)  # logto_id -> accounts (detect N:1 collisions)
+
+    for acc in accounts:
+        uuid = (acc.get("uuid") or "").strip()
+        noc_r = noc_by_uuid.get(uuid)
+        if not noc_r:
+            icaro_only.append(acc)
+            continue
+
+        candidates, method = [], None
+        for key, m in ((uuid.lower(), "uuid"), ((noc_r.get("piva") or "").strip().lower(), "piva")):
+            if key and orgs_by_token.get(key):
+                candidates, method = orgs_by_token[key], m
+                break
+        if not candidates:
+            nname = norm_name(noc_r.get("company") or acc.get("username"))
+            if nname and orgs_by_name.get(nname):
+                candidates, method = orgs_by_name[nname], "name"
+
+        uniq = {o["logto_id"]: o for o in candidates}
+        if len(uniq) == 1:
+            org = next(iter(uniq.values()))
+            matched.append((acc, org, method))
+            used_orgs[org["logto_id"]].append(acc)
+        elif len(uniq) > 1:
+            ambiguous.append((acc, list(uniq.values()), method))
+        else:
+            no_my_org.append((acc, noc_r))
+
+    # An org linked to more than one account is a conflict: move to ambiguous
+    conflicts = {oid for oid, accs in used_orgs.items() if len(accs) > 1}
+    matched, dropped = [m for m in matched if m[1]["logto_id"] not in conflicts], [m for m in matched if m[1]["logto_id"] in conflicts]
+    for acc, org, method in dropped:
+        ambiguous.append((acc, [org], method + " (org linked by multiple accounts)"))
+
+    os.makedirs(args.output_dir, exist_ok=True)
+
+    def out(name):
+        return open(os.path.join(args.output_dir, name), "w", newline="", encoding="utf-8")
+
+    with out("matched.sql") as f:
+        f.write("-- Review before applying: mysql icaro < matched.sql\n")
+        for acc, org, method in matched:
+            f.write(
+                "UPDATE accounts SET logto_org_id = '%s' WHERE id = %s AND logto_org_id IS NULL; -- %s -> %s [%s]\n"
+                % (org["logto_id"], acc["id"], acc.get("username"), org.get("name"), method)
+            )
+
+    with out("report_matched.csv") as f:
+        w = csv.writer(f)
+        w.writerow(["account_id", "username", "email", "logto_org_id", "org_name", "method"])
+        for acc, org, method in matched:
+            w.writerow([acc["id"], acc.get("username"), acc.get("email"), org["logto_id"], org.get("name"), method])
+
+    with out("report_icaro_only.csv") as f:
+        w = csv.writer(f)
+        w.writerow(["account_id", "username", "email"])
+        for acc in icaro_only:
+            w.writerow([acc["id"], acc.get("username"), acc.get("email")])
+
+    with out("report_no_my_org.csv") as f:
+        w = csv.writer(f)
+        w.writerow(["account_id", "username", "noc_company", "noc_piva", "noc_email"])
+        for acc, noc_r in no_my_org:
+            w.writerow([acc["id"], acc.get("username"), noc_r.get("company"), noc_r.get("piva"), noc_r.get("email")])
+
+    with out("report_ambiguous.csv") as f:
+        w = csv.writer(f)
+        w.writerow(["account_id", "username", "method", "candidate_orgs"])
+        for acc, cands, method in ambiguous:
+            w.writerow([acc["id"], acc.get("username"), method, "; ".join("%s (%s)" % (o["logto_id"], o.get("name")) for o in cands)])
+
+    print("accounts:   %d" % len(accounts))
+    print("matched:    %d  -> matched.sql" % len(matched))
+    print("icaro-only: %d  (classic login, untouched)" % len(icaro_only))
+    print("no my org:  %d  (on noc but not migrated to new my)" % len(no_my_org))
+    print("ambiguous:  %d  (resolve by hand)" % len(ambiguous))
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/sun/sun-api/configuration/configuration.go b/sun/sun-api/configuration/configuration.go
index 633d464e3..542428c42 100644
--- a/sun/sun-api/configuration/configuration.go
+++ b/sun/sun-api/configuration/configuration.go
@@ -53,6 +53,39 @@ type Configuration struct {
 		Origins []string `json:"origins"`
 		Methods []string `json:"methods"`
 	} `json:"cors"`
+	OIDC struct {
+		Issuer       string   `json:"issuer"`
+		ClientID     string   `json:"client_id"`
+		ClientSecret string   `json:"client_secret"`
+		RedirectURI  string   `json:"redirect_uri"`
+		FrontendURL  string   `json:"frontend_url"`
+		Scopes       []string `json:"scopes"`
+		RoleMapping  []string `json:"role_mapping"`
+		// Company (organization) login: users that hold the OrgAdminRole
+		// global role on My and belong to an organization whose
+		// organization-role is OrgResellerRole log in as the Icaro account
+		// linked to that organization via accounts.logto_org_id
+		OrgAdminRole    string `json:"org_admin_role"`
+		OrgResellerRole string `json:"org_reseller_role"`
+		// My API access for JIT provisioning of company accounts:
+		// if unset, only pre-linked organizations can log in
+		MyAPIURL                  string `json:"my_api_url"`
+		MyAPIKey                  string `json:"my_api_key"`
+		DefaultSubscriptionPlanID int    `json:"default_subscription_plan_id"`
+		// Audience expected on the ID token presented to /api/userinfo
+		// (My's own Logto app id); empty = audience check disabled
+		UserinfoAudience string `json:"userinfo_audience"`
+		// Browser origins allowed to call /api/userinfo cross-origin (the
+		// My dashboard, per environment). Separate from the Sun frontend
+		// CORS above: only /api/userinfo honors these.
+		UserinfoOrigins []string `json:"userinfo_origins"`
+		// Whether the "Login with My Nethesis" button is shown on the
+		// Sun login page. When false the OIDC endpoints stay fully
+		// functional (so the entry from the My dashboard keeps working),
+		// only the button is hidden — used for a soft launch where the
+		// flow is reachable from My but not advertised on Sun itself.
+		ShowLoginButton *bool `json:"show_login_button"`
+	} `json:"oidc"`
 	Disclaimers struct {
 		TermsOfUse   string `json:"terms_of_use"`
 		MarketingUse string `json:"marketing_use"`
@@ -223,6 +256,75 @@ func Init(ConfigFilePtr *string) {
 		Config.Survey.Url = os.Getenv("SURVEY_URL")
 	}
 
+	if os.Getenv("OIDC_ISSUER") != "" {
+		Config.OIDC.Issuer = os.Getenv("OIDC_ISSUER")
+	}
+	if os.Getenv("OIDC_CLIENT_ID") != "" {
+		Config.OIDC.ClientID = os.Getenv("OIDC_CLIENT_ID")
+	}
+	if os.Getenv("OIDC_CLIENT_SECRET") != "" {
+		Config.OIDC.ClientSecret = os.Getenv("OIDC_CLIENT_SECRET")
+	}
+	if os.Getenv("OIDC_REDIRECT_URI") != "" {
+		Config.OIDC.RedirectURI = os.Getenv("OIDC_REDIRECT_URI")
+	}
+	if os.Getenv("OIDC_FRONTEND_URL") != "" {
+		Config.OIDC.FrontendURL = os.Getenv("OIDC_FRONTEND_URL")
+	}
+	if os.Getenv("OIDC_SCOPES") != "" {
+		// scope names never contain spaces or commas: accept both separators
+		Config.OIDC.Scopes = strings.FieldsFunc(os.Getenv("OIDC_SCOPES"), func(r rune) bool {
+			return r == ' ' || r == ','
+		})
+	}
+	if os.Getenv("OIDC_ROLE_MAPPING") != "" {
+		// comma-separated only: role names may contain spaces ("super admin:admin")
+		var roleMappings []string
+		for _, mapping := range strings.Split(os.Getenv("OIDC_ROLE_MAPPING"), ",") {
+			if trimmed := strings.TrimSpace(mapping); trimmed != "" {
+				roleMappings = append(roleMappings, trimmed)
+			}
+		}
+		Config.OIDC.RoleMapping = roleMappings
+	}
+	if os.Getenv("OIDC_ORG_ADMIN_ROLE") != "" {
+		Config.OIDC.OrgAdminRole = os.Getenv("OIDC_ORG_ADMIN_ROLE")
+	}
+	if os.Getenv("OIDC_ORG_RESELLER_ROLE") != "" {
+		Config.OIDC.OrgResellerRole = os.Getenv("OIDC_ORG_RESELLER_ROLE")
+	}
+	if os.Getenv("OIDC_USERINFO_AUDIENCE") != "" {
+		Config.OIDC.UserinfoAudience = os.Getenv("OIDC_USERINFO_AUDIENCE")
+	}
+	if os.Getenv("OIDC_USERINFO_ORIGINS") != "" {
+		// space or comma separated list of allowed My dashboard origins
+		Config.OIDC.UserinfoOrigins = strings.FieldsFunc(os.Getenv("OIDC_USERINFO_ORIGINS"), func(r rune) bool {
+			return r == ' ' || r == ','
+		})
+	}
+	if os.Getenv("OIDC_SHOW_LOGIN_BUTTON") != "" {
+		showButton, _ := strconv.ParseBool(os.Getenv("OIDC_SHOW_LOGIN_BUTTON"))
+		Config.OIDC.ShowLoginButton = &showButton
+	}
+	if os.Getenv("OIDC_MY_API_URL") != "" {
+		Config.OIDC.MyAPIURL = os.Getenv("OIDC_MY_API_URL")
+	}
+	if os.Getenv("OIDC_MY_API_KEY") != "" {
+		Config.OIDC.MyAPIKey = os.Getenv("OIDC_MY_API_KEY")
+	}
+	if os.Getenv("OIDC_DEFAULT_SUBSCRIPTION_PLAN_ID") != "" {
+		Config.OIDC.DefaultSubscriptionPlanID, _ = strconv.Atoi(os.Getenv("OIDC_DEFAULT_SUBSCRIPTION_PLAN_ID"))
+	}
+
+	// Defaults for company OIDC login: on My "Admin" is the global user
+	// role and the organization-role encodes the organization type
+	if Config.OIDC.OrgAdminRole == "" {
+		Config.OIDC.OrgAdminRole = "Admin"
+	}
+	if Config.OIDC.OrgResellerRole == "" {
+		Config.OIDC.OrgResellerRole = "Reseller"
+	}
+
 	Config.CaptivePortal.LogoContents = ""
 	if _, err := os.Stat(Config.CaptivePortal.Logo); err == nil {
 		if data, errRead := ioutil.ReadFile(Config.CaptivePortal.Logo); errRead == nil {
diff --git a/sun/sun-api/main.go b/sun/sun-api/main.go
index 9033491e8..43fc2ab4d 100644
--- a/sun/sun-api/main.go
+++ b/sun/sun-api/main.go
@@ -36,12 +36,41 @@ import (
 )
 
 func DefineAPI(router *gin.Engine) {
-	// cors
+	// Global CORS for the Sun frontend talking to its own API
 	corsConf := cors.DefaultConfig()
 	corsConf.AllowOrigins = configuration.Config.Cors.Origins
 	corsConf.AllowHeaders = configuration.Config.Cors.Headers
 	corsConf.AllowMethods = configuration.Config.Cors.Methods
-	router.Use(cors.New(corsConf))
+	globalCORS := cors.New(corsConf)
+
+	// /api/userinfo is called cross-origin by the My dashboard widget with
+	// its own allowlist (the My origins per environment), distinct from the
+	// Sun frontend CORS above. Handle it here — reflecting the matched
+	// origin, answering the OPTIONS preflight with 204 — so the global
+	// middleware never rejects those origins. The Bearer token check in the
+	// handler is unchanged: CORS is additive, not a substitute for auth.
+	userinfoOrigins := configuration.Config.OIDC.UserinfoOrigins
+	router.Use(func(c *gin.Context) {
+		if c.Request.URL.Path != "/api/userinfo" {
+			globalCORS(c)
+			return
+		}
+		origin := c.GetHeader("Origin")
+		for _, allowed := range userinfoOrigins {
+			if origin != "" && origin == allowed {
+				c.Header("Access-Control-Allow-Origin", origin)
+				c.Header("Vary", "Origin")
+				c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
+				c.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
+				break
+			}
+		}
+		if c.Request.Method == http.MethodOptions {
+			c.AbortWithStatus(http.StatusNoContent)
+			return
+		}
+		c.Next()
+	})
 
 	health := router.Group("/health")
 	health.GET("/check", methods.HealthCheck)
@@ -50,6 +79,13 @@ func DefineAPI(router *gin.Engine) {
 
 	api.POST("/login", methods.Login)
 	api.POST("/logout", methods.Logout)
+	api.GET("/userinfo", methods.UserInfo)
+	api.GET("/auth/oidc/config", methods.GetOIDCConfig)
+	api.GET("/auth/oidc/login", methods.OIDCLogin)
+	api.GET("/auth/oidc/callback", methods.OIDCCallback)
+	api.POST("/auth/oidc/exchange", methods.OIDCExchange)
+	api.POST("/auth/oidc/device/start", methods.OIDCDeviceStart)
+	api.POST("/auth/oidc/device/poll", methods.OIDCDevicePoll)
 
 	api.Use(middleware.AAWall)
 	{
diff --git a/sun/sun-api/methods/authentication.go b/sun/sun-api/methods/authentication.go
index 3606ab3f8..068472461 100644
--- a/sun/sun-api/methods/authentication.go
+++ b/sun/sun-api/methods/authentication.go
@@ -23,14 +23,24 @@
 package methods
 
 import (
+	"context"
 	"crypto/md5"
+	"crypto/rand"
 	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
 	"fmt"
+	"log"
 	"net/http"
+	"net/url"
+	"strings"
+	"sync"
 	"time"
 
+	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/gin-gonic/gin"
 	_ "github.com/jinzhu/gorm/dialects/mysql"
+	"golang.org/x/oauth2"
 
 	"github.com/nethesis/icaro/sun/sun-api/configuration"
 	"github.com/nethesis/icaro/sun/sun-api/database"
@@ -38,6 +48,39 @@ import (
 	"github.com/nethesis/icaro/sun/sun-api/utils"
 )
 
+// Global cleanup context and cancel function
+var (
+	cleanupCtx    context.Context
+	cleanupCancel context.CancelFunc
+)
+
+// Cached OIDC provider: discovery and JWKS are fetched once and reused
+// across logins instead of hitting the identity provider on every request
+var (
+	oidcProviderMu sync.Mutex
+	oidcProvider   *oidc.Provider
+)
+
+// getOIDCProvider returns the cached OIDC provider, initializing it on
+// first use. The provider is created with a background context because it
+// outlives the request that triggered its initialization.
+func getOIDCProvider(issuer string) (*oidc.Provider, error) {
+	oidcProviderMu.Lock()
+	defer oidcProviderMu.Unlock()
+
+	if oidcProvider != nil {
+		return oidcProvider, nil
+	}
+
+	provider, err := oidc.NewProvider(context.Background(), issuer)
+	if err != nil {
+		return nil, err
+	}
+
+	oidcProvider = provider
+	return provider, nil
+}
+
 func Login(c *gin.Context) {
 	var account models.Account
 	var subscription models.Subscription
@@ -121,3 +164,891 @@ func Logout(c *gin.Context) {
 		c.JSON(http.StatusOK, gin.H{"status": "success"})
 	}
 }
+
+// Name of the cookie that binds the OIDC state to the browser that
+// initiated the login flow (prevents login CSRF / session fixation)
+const oidcStateCookieName = "icaro_oidc_state"
+
+// OIDCStateData holds the per-login-attempt secrets generated at /login
+// and needed again at /callback
+type OIDCStateData struct {
+	Expires      time.Time
+	Nonce        string
+	PKCEVerifier string
+	// PairID links this login attempt to a device pairing started via
+	// /auth/oidc/device/start (empty for regular SPA logins)
+	PairID string
+}
+
+// OIDCStateStore manages OIDC state tokens with expiration
+type OIDCStateStore struct {
+	mu     sync.RWMutex
+	states map[string]OIDCStateData
+}
+
+// OIDCCodeStore manages temporary codes for secure token exchange
+type OIDCCodeStore struct {
+	mu    sync.RWMutex
+	codes map[string]OIDCCodeData
+}
+
+type OIDCCodeData struct {
+	Token     string
+	AccountID int
+	Expires   time.Time
+}
+
+// Global state store instance
+var stateStore = &OIDCStateStore{
+	states: make(map[string]OIDCStateData),
+}
+
+// Global code store instance
+var codeStore = &OIDCCodeStore{
+	codes: make(map[string]OIDCCodeData),
+}
+
+// maxPendingOIDCStates bounds the memory used by pending login attempts:
+// above this threshold new attempts are rejected until older ones expire
+// (10 minutes) or complete, so spamming /auth/oidc/login cannot grow the
+// store without limit
+const maxPendingOIDCStates = 10000
+
+// StoreState stores a state token with its associated per-attempt data.
+// Returns false if the store is full.
+func (s *OIDCStateStore) StoreState(state string, data OIDCStateData) bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if len(s.states) >= maxPendingOIDCStates {
+		// purge expired entries before giving up
+		now := time.Now()
+		for st, d := range s.states {
+			if now.After(d.Expires) {
+				delete(s.states, st)
+			}
+		}
+		if len(s.states) >= maxPendingOIDCStates {
+			return false
+		}
+	}
+
+	s.states[state] = data
+	return true
+}
+
+// ValidateAndRemoveState checks if state is valid and removes it
+func (s *OIDCStateStore) ValidateAndRemoveState(state string) (OIDCStateData, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	data, exists := s.states[state]
+	if !exists {
+		return OIDCStateData{}, false
+	}
+
+	// Remove the state regardless of expiration (one-time use)
+	delete(s.states, state)
+
+	// Check if state has expired
+	if time.Now().After(data.Expires) {
+		return OIDCStateData{}, false
+	}
+
+	return data, true
+}
+
+// CleanupExpiredStates removes expired states (should be called periodically)
+func (s *OIDCStateStore) CleanupExpiredStates() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	now := time.Now()
+	for state, data := range s.states {
+		if now.After(data.Expires) {
+			delete(s.states, state)
+		}
+	}
+}
+
+// StoreCode stores a temporary code with token and account data
+func (c *OIDCCodeStore) StoreCode(code string, token string, accountID int, expiration time.Time) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.codes[code] = OIDCCodeData{
+		Token:     token,
+		AccountID: accountID,
+		Expires:   expiration,
+	}
+}
+
+// ValidateAndRemoveCode checks if code is valid and removes it (one-time use)
+func (c *OIDCCodeStore) ValidateAndRemoveCode(code string) (OIDCCodeData, bool) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	data, exists := c.codes[code]
+	if !exists {
+		return OIDCCodeData{}, false
+	}
+
+	// Remove the code regardless of expiration (one-time use)
+	delete(c.codes, code)
+
+	// Check if code has expired
+	if time.Now().After(data.Expires) {
+		return OIDCCodeData{}, false
+	}
+
+	return data, true
+}
+
+// CleanupExpiredCodes removes expired codes
+func (c *OIDCCodeStore) CleanupExpiredCodes() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	now := time.Now()
+	for code, data := range c.codes {
+		if now.After(data.Expires) {
+			delete(c.codes, code)
+		}
+	}
+}
+
+// Initialize cleanup routine with graceful shutdown support
+func init() {
+	cleanupCtx, cleanupCancel = context.WithCancel(context.Background())
+
+	go func() {
+		ticker := time.NewTicker(5 * time.Minute)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-cleanupCtx.Done():
+				return
+			case <-ticker.C:
+				stateStore.CleanupExpiredStates()
+				codeStore.CleanupExpiredCodes()
+				pairingStore.CleanupExpiredPairings()
+			}
+		}
+	}()
+}
+
+// StopCleanupRoutine stops the background cleanup goroutine gracefully
+func StopCleanupRoutine() {
+	if cleanupCancel != nil {
+		cleanupCancel()
+	}
+}
+
+// generateSecureRandomString generates a cryptographically secure random
+// URL-safe string (used for state, nonce and PKCE verifier)
+func generateSecureRandomString() (string, error) {
+	b := make([]byte, 32)
+	_, err := rand.Read(b)
+	if err != nil {
+		return "", err
+	}
+	return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// pkceChallengeS256 derives the S256 code challenge from a PKCE verifier
+func pkceChallengeS256(verifier string) string {
+	sum := sha256.Sum256([]byte(verifier))
+	return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+// oidcFail reports a callback failure to the right consumer: the SPA via
+// redirect for regular logins, the polling unit (plus a minimal page in
+// the popup) for device pairing flows
+func oidcFail(c *gin.Context, pairID string, errorCode string) {
+	if pairID != "" {
+		pairingStore.Fail(pairID, errorCode)
+		pairingResultPage(c, false, "Unit not linked",
+			fmt.Sprintf("The unit could not be linked (%s).", errorCode), nil)
+		return
+	}
+	c.Redirect(http.StatusTemporaryRedirect, configuration.Config.OIDC.FrontendURL+"/?error="+errorCode)
+}
+
+// createOAuth2Config creates the OAuth2 configuration based on the OIDC config
+func createOAuth2Config(config configuration.Configuration, provider *oidc.Provider) oauth2.Config {
+	scopes := []string{oidc.ScopeOpenID, "profile", "email"}
+
+	// Add configured scopes if available, otherwise use defaults
+	if len(config.OIDC.Scopes) > 0 {
+		scopes = config.OIDC.Scopes
+	} else {
+		// Default scopes for Logto
+		scopes = append(scopes, "roles", "urn:logto:scope:organizations", "urn:logto:scope:organization_roles")
+	}
+
+	return oauth2.Config{
+		ClientID:     config.OIDC.ClientID,
+		ClientSecret: config.OIDC.ClientSecret,
+		RedirectURL:  config.OIDC.RedirectURI,
+		Endpoint:     provider.Endpoint(),
+		Scopes:       scopes,
+	}
+}
+
+// hasRoleIgnoreCase reports whether the given role appears in roles
+func hasRoleIgnoreCase(roles []string, want string) bool {
+	for _, role := range roles {
+		if strings.EqualFold(role, want) {
+			return true
+		}
+	}
+	return false
+}
+
+// extractOrgIDsWithRole returns the IDs of the organizations where the user
+// holds the given role, from the Logto "organization_roles" claim
+// (entries have the form "<organization_id>:<role_name>")
+func extractOrgIDsWithRole(claims map[string]interface{}, adminRole string) []string {
+	var orgIDs []string
+
+	rolesClaim, ok := claims["organization_roles"].([]interface{})
+	if !ok {
+		return orgIDs
+	}
+
+	for _, entry := range rolesClaim {
+		entryStr, ok := entry.(string)
+		if !ok {
+			continue
+		}
+		idx := strings.LastIndex(entryStr, ":")
+		if idx <= 0 {
+			continue
+		}
+		if strings.EqualFold(entryStr[idx+1:], adminRole) {
+			orgIDs = append(orgIDs, entryStr[:idx])
+		}
+	}
+
+	return orgIDs
+}
+
+// extractRolesFromClaims extracts roles from all possible claim fields.
+// All fields are collected (not just the first non-empty one) so that
+// e.g. Logto organization roles are considered even when the user also
+// has unrelated global roles.
+func extractRolesFromClaims(claims map[string]interface{}) []string {
+	var roles []string
+
+	// List of possible role claim fields
+	roleFields := []string{"roles", "role", "groups", "organizations", "organization_roles"}
+
+	for _, field := range roleFields {
+		fieldValue, exists := claims[field]
+		if !exists {
+			continue
+		}
+		// Handle array of roles
+		if roleSlice, ok := fieldValue.([]interface{}); ok {
+			for _, role := range roleSlice {
+				if roleStr, ok := role.(string); ok {
+					roles = append(roles, roleStr)
+				}
+			}
+		}
+		// Handle single role as string
+		if roleStr, ok := fieldValue.(string); ok {
+			roles = append(roles, roleStr)
+		}
+	}
+
+	return roles
+}
+
+// mapRoleToIcaro maps external roles to Icaro roles based on configuration.
+// Mappings are evaluated in configuration order, so the first mapping wins:
+// list the most privileged roles first (e.g. "super admin:admin" before
+// "admin:reseller"). Returns empty string if no role is authorized.
+func mapRoleToIcaro(externalRoles []string, config configuration.Configuration) string {
+	// If no role mapping configured, deny access - no default mappings for security
+	for _, mapping := range config.OIDC.RoleMapping {
+		// Parse role mapping from configuration: "external:internal" format
+		parts := strings.Split(mapping, ":")
+		if len(parts) != 2 {
+			continue
+		}
+		configRole := strings.TrimSpace(parts[0])
+		icaroRole := strings.TrimSpace(parts[1])
+
+		for _, externalRole := range externalRoles {
+			if strings.EqualFold(externalRole, configRole) {
+				return icaroRole
+			}
+			// Logto organization roles come as "<organization_id>:<role_name>":
+			// match the role name part as well
+			if idx := strings.LastIndex(externalRole, ":"); idx >= 0 {
+				if strings.EqualFold(externalRole[idx+1:], configRole) {
+					return icaroRole
+				}
+			}
+		}
+	}
+
+	// Return empty string if no authorized role found
+	return ""
+}
+
+// validateOIDCConfig validates that OIDC configuration is complete
+func validateOIDCConfig(config configuration.Configuration) error {
+	if config.OIDC.Issuer == "" {
+		return fmt.Errorf("OIDC issuer not configured")
+	}
+	if config.OIDC.ClientID == "" {
+		return fmt.Errorf("OIDC client ID not configured")
+	}
+	if config.OIDC.ClientSecret == "" {
+		return fmt.Errorf("OIDC client secret not configured")
+	}
+	if config.OIDC.RedirectURI == "" {
+		return fmt.Errorf("OIDC redirect URI not configured")
+	}
+	if config.OIDC.FrontendURL == "" {
+		return fmt.Errorf("OIDC frontend URL not configured")
+	}
+	return nil
+}
+
+// myResellerResponse mirrors the relevant part of My's GET /resellers/:id
+type myResellerResponse struct {
+	Code int `json:"code"`
+	Data struct {
+		ID          string                 `json:"id"`
+		LogtoID     *string                `json:"logto_id"`
+		Name        string                 `json:"name"`
+		CustomData  map[string]interface{} `json:"custom_data"`
+		SuspendedAt *string                `json:"suspended_at"`
+	} `json:"data"`
+}
+
+// provisionCompanyAccount creates the Icaro company account for a My
+// reseller organization on its first OIDC login. The organization is
+// verified against the My API (must exist as an active reseller) before
+// anything is created. Returns the account, or a frontend error code.
+func provisionCompanyAccount(orgID string) (*models.Account, string) {
+	config := configuration.Config
+
+	if config.OIDC.MyAPIURL == "" || config.OIDC.MyAPIKey == "" || config.OIDC.DefaultSubscriptionPlanID == 0 {
+		// JIT provisioning not configured: only organizations already
+		// linked to an account may log in
+		log.Println("OIDC company login denied: organization", orgID, "not linked and JIT provisioning not configured")
+		return nil, "account_not_found"
+	}
+
+	// Verify the organization on My: GET /resellers/:id looks up by
+	// Logto organization ID and excludes deleted organizations
+	req, err := http.NewRequest("GET", strings.TrimRight(config.OIDC.MyAPIURL, "/")+"/resellers/"+url.PathEscape(orgID), nil)
+	if err != nil {
+		log.Println("OIDC provisioning: request creation failed:", err)
+		return nil, "provisioning_failed"
+	}
+	req.Header.Set("Authorization", "Bearer "+config.OIDC.MyAPIKey)
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		log.Println("OIDC provisioning: My API unreachable:", err)
+		return nil, "provisioning_failed"
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusNotFound {
+		// The organization exists on Logto but is not a reseller on My
+		log.Println("OIDC company login denied: organization", orgID, "is not a reseller on My")
+		return nil, "account_not_found"
+	}
+	if resp.StatusCode != http.StatusOK {
+		log.Println("OIDC provisioning: My API returned status", resp.StatusCode, "for organization", orgID)
+		return nil, "provisioning_failed"
+	}
+
+	var myResp myResellerResponse
+	if err := json.NewDecoder(resp.Body).Decode(&myResp); err != nil {
+		log.Println("OIDC provisioning: invalid My API response:", err)
+		return nil, "provisioning_failed"
+	}
+
+	if myResp.Data.SuspendedAt != nil {
+		log.Println("OIDC company login denied: organization", orgID, "is suspended on My")
+		return nil, "org_suspended"
+	}
+
+	// Organization contact email from custom data, when available
+	orgEmail := ""
+	for _, key := range []string{"email", "contactEmail", "contact_email"} {
+		if v, ok := myResp.Data.CustomData[key].(string); ok && v != "" {
+			orgEmail = v
+			break
+		}
+	}
+
+	name := myResp.Data.Name
+	if name == "" {
+		name = orgID
+	}
+
+	// JIT company accounts are created by the primary admin
+	db := database.Instance()
+	var admin models.Account
+	db.Where("type = ?", "admin").Order("id ASC").First(&admin)
+
+	logtoOrgID := orgID
+	account := models.Account{
+		CreatorId:  admin.Id,
+		Uuid:       orgID, // stable unique identifier for accounts born from My organizations
+		LogtoOrgId: &logtoOrgID,
+		Type:       "reseller",
+		Name:       name,
+		Username:   orgID,
+		Password:   "", // OIDC-only account, no password login
+		Email:      orgEmail,
+		Created:    time.Now().UTC(),
+	}
+	if err := db.Create(&account).Error; err != nil {
+		log.Println("OIDC provisioning: account creation failed for organization", orgID, ":", err)
+		return nil, "account_creation_failed"
+	}
+
+	// Create the subscription with the configured default plan and the
+	// SMS accounting, mirroring the classic account creation
+	var subscriptionPlan models.SubscriptionPlan
+	db.Where("id = ?", config.OIDC.DefaultSubscriptionPlanID).First(&subscriptionPlan)
+	if subscriptionPlan.ID == 0 {
+		log.Println("OIDC provisioning: default subscription plan", config.OIDC.DefaultSubscriptionPlanID, "not found")
+		return nil, "account_creation_failed"
+	}
+
+	subscription := models.Subscription{
+		AccountID:          account.Id,
+		SubscriptionPlanID: subscriptionPlan.ID,
+		ValidFrom:          time.Now().UTC(),
+		ValidUntil:         time.Now().UTC().AddDate(0, 0, subscriptionPlan.Period),
+		Created:            time.Now().UTC(),
+	}
+	db.Save(&subscription)
+
+	accountSMS := models.AccountSmsCount{
+		AccountId:   account.Id,
+		SmsMaxCount: subscriptionPlan.IncludedSMS,
+	}
+	db.Save(&accountSMS)
+
+	log.Println("OIDC provisioning: created company account", account.Id, "for My organization", orgID, "("+name+")")
+	return &account, ""
+}
+
+func GetOIDCConfig(c *gin.Context) {
+	config := configuration.Config
+
+	enabled := config.OIDC.Issuer != "" && config.OIDC.ClientID != ""
+
+	// The button is shown only when OIDC works and it is not explicitly
+	// hidden. Hiding it (show_login_button=false) keeps the OIDC endpoints
+	// live for the entry from the My dashboard while removing the button
+	// from the Sun login page (soft launch).
+	showButton := enabled && (config.OIDC.ShowLoginButton == nil || *config.OIDC.ShowLoginButton)
+
+	// Return only public configuration information
+	response := gin.H{
+		"enabled":      enabled,
+		"login_button": showButton,
+	}
+
+	// Only add provider name if OIDC is properly configured
+	if enabled {
+		response["provider_name"] = "My Nethesis"
+	}
+
+	c.JSON(http.StatusOK, response)
+}
+
+func OIDCLogin(c *gin.Context) {
+	config := configuration.Config
+
+	// Validate OIDC configuration
+	if err := validateOIDCConfig(config); err != nil {
+		log.Println("OIDC configuration error:", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "OIDC configuration error"})
+		return
+	}
+
+	// Always use auto-discovery for endpoints (cached after first use)
+	provider, err := getOIDCProvider(config.OIDC.Issuer)
+	if err != nil {
+		log.Println("OIDC provider initialization failed:", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to get OIDC provider"})
+		return
+	}
+
+	oauth2Config := createOAuth2Config(config, provider)
+
+	// Device pairing flow: bind this login attempt to a pending pairing
+	// created via /auth/oidc/device/start
+	pairID := c.Query("pair")
+	if pairID != "" && !pairingStore.IsPending(pairID) {
+		c.JSON(http.StatusBadRequest, gin.H{"message": "Unknown or expired pairing request"})
+		return
+	}
+
+	// Generate secure random state, nonce and PKCE verifier
+	state, err := generateSecureRandomString()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate state"})
+		return
+	}
+	nonce, err := generateSecureRandomString()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate nonce"})
+		return
+	}
+	pkceVerifier, err := generateSecureRandomString()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate PKCE verifier"})
+		return
+	}
+
+	// Store state with expiration (valid for 10 minutes)
+	stored := stateStore.StoreState(state, OIDCStateData{
+		Expires:      time.Now().Add(10 * time.Minute),
+		Nonce:        nonce,
+		PKCEVerifier: pkceVerifier,
+		PairID:       pairID,
+	})
+	if !stored {
+		log.Println("OIDC state store is full, rejecting login attempt")
+		c.JSON(http.StatusServiceUnavailable, gin.H{"message": "Too many pending login attempts, retry later"})
+		return
+	}
+
+	// Bind the state to this browser with a short-lived HttpOnly cookie:
+	// the callback only accepts a state that matches the cookie, so a state
+	// minted for another browser cannot be replayed (login CSRF protection).
+	// SameSite=Lax cookies are sent on the top-level GET navigation back
+	// from the identity provider.
+	c.SetSameSite(http.SameSiteLaxMode)
+	c.SetCookie(oidcStateCookieName, state, 600, "/", "", strings.HasPrefix(config.OIDC.RedirectURI, "https://"), true)
+
+	// Get authorization URL (with nonce and PKCE challenge)
+	authURL := oauth2Config.AuthCodeURL(
+		state,
+		oauth2.SetAuthURLParam("nonce", nonce),
+		oauth2.SetAuthURLParam("code_challenge", pkceChallengeS256(pkceVerifier)),
+		oauth2.SetAuthURLParam("code_challenge_method", "S256"),
+	)
+
+	// Redirect to authorization URL
+	c.Redirect(http.StatusTemporaryRedirect, authURL)
+}
+
+func OIDCCallback(c *gin.Context) {
+	config := configuration.Config
+	ctx := context.Background()
+
+	// Validate OIDC configuration
+	if err := validateOIDCConfig(config); err != nil {
+		log.Println("OIDC configuration error:", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "OIDC configuration error"})
+		return
+	}
+
+	// Get authorization code and state from query parameters
+	code := c.Query("code")
+	state := c.Query("state")
+
+	if code == "" {
+		c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=missing_code")
+		return
+	}
+
+	if state == "" {
+		c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=missing_state")
+		return
+	}
+
+	// The state must match the cookie set when this browser started the
+	// flow: a valid state minted for a different browser is rejected
+	// (login CSRF protection)
+	stateCookie, err := c.Cookie(oidcStateCookieName)
+	if err != nil || stateCookie != state {
+		c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=invalid_state")
+		return
+	}
+
+	// Clear the state cookie: it is one-time use like the state itself
+	c.SetSameSite(http.SameSiteLaxMode)
+	c.SetCookie(oidcStateCookieName, "", -1, "/", "", strings.HasPrefix(config.OIDC.RedirectURI, "https://"), true)
+
+	// Validate state and retrieve the nonce and PKCE verifier of this attempt
+	stateData, valid := stateStore.ValidateAndRemoveState(state)
+	if !valid {
+		c.Redirect(http.StatusTemporaryRedirect, config.OIDC.FrontendURL+"/?error=invalid_state")
+		return
+	}
+
+	// Initialize OIDC provider (cached after first use)
+	provider, err := getOIDCProvider(config.OIDC.Issuer)
+	if err != nil {
+		log.Println("OIDC provider initialization failed:", err)
+		oidcFail(c, stateData.PairID, "provider_init_failed")
+		return
+	}
+
+	oauth2Config := createOAuth2Config(config, provider)
+
+	// Exchange authorization code for token (PKCE verifier proves this is
+	// the same client that started the flow)
+	token, err := oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", stateData.PKCEVerifier))
+	if err != nil {
+		log.Println("OIDC token exchange failed:", err)
+		oidcFail(c, stateData.PairID, "token_exchange_failed")
+		return
+	}
+
+	// Extract and verify ID token
+	rawIDToken, ok := token.Extra("id_token").(string)
+	if !ok {
+		oidcFail(c, stateData.PairID, "no_id_token")
+		return
+	}
+
+	// Create verifier
+	oidcConfig := &oidc.Config{
+		ClientID: config.OIDC.ClientID,
+	}
+	verifier := provider.Verifier(oidcConfig)
+
+	// Verify ID token
+	idToken, err := verifier.Verify(ctx, rawIDToken)
+	if err != nil {
+		log.Println("OIDC ID token verification failed:", err)
+		oidcFail(c, stateData.PairID, "token_verification_failed")
+		return
+	}
+
+	// The ID token must carry the nonce generated for this attempt:
+	// binds the token to this login flow and prevents replay
+	if idToken.Nonce != stateData.Nonce {
+		oidcFail(c, stateData.PairID, "invalid_nonce")
+		return
+	}
+
+	// Extract claims
+	var claims map[string]interface{}
+	if err := idToken.Claims(&claims); err != nil {
+		oidcFail(c, stateData.PairID, "claims_extraction_failed")
+		return
+	}
+
+	// Extract user information
+	sub, _ := claims["sub"].(string)
+	email, _ := claims["email"].(string)
+
+	if sub == "" || email == "" {
+		oidcFail(c, stateData.PairID, "missing_user_info")
+		return
+	}
+
+	// Extract and map global roles: the only mapping expected here is
+	// "super admin:admin" (My super admins act as the Icaro admin); any
+	// other user logs in as their company via the organization claims
+	roles := extractRolesFromClaims(claims)
+	icaroRole := mapRoleToIcaro(roles, config)
+
+	// Handle account mapping based on role
+	db := database.Instance()
+	var account models.Account
+
+	if icaroRole == "admin" {
+		// If user should be admin, find the primary admin account (lowest ID)
+		db.Where("type = ?", "admin").Order("id ASC").First(&account)
+
+		if account.Id == 0 {
+			// No admin account found, create error
+			oidcFail(c, stateData.PairID, "no_admin_account_found")
+			return
+		}
+
+		// Use the existing admin account - don't modify it
+		// This allows super admin from Logto to act as the main admin
+	} else {
+		// Company login: on My the global role says what the user may do
+		// ("Admin" of their company) and the organization-role encodes
+		// the organization type ("Reseller"). Both are required; the
+		// organization then maps to the Icaro company account through
+		// accounts.logto_org_id (immutable link, never matched by email)
+		if !hasRoleIgnoreCase(roles, config.OIDC.OrgAdminRole) {
+			log.Printf("OIDC company login denied for %s: missing global role %q (roles=%v)",
+				email, config.OIDC.OrgAdminRole, claims["roles"])
+			oidcFail(c, stateData.PairID, "unauthorized_role")
+			return
+		}
+
+		adminOrgIDs := extractOrgIDsWithRole(claims, config.OIDC.OrgResellerRole)
+		if len(adminOrgIDs) == 0 {
+			log.Printf("OIDC company login denied for %s: no organization with role %q (organization_roles=%v)",
+				email, config.OIDC.OrgResellerRole, claims["organization_roles"])
+			oidcFail(c, stateData.PairID, "unauthorized_role")
+			return
+		}
+
+		db.Where("logto_org_id IN (?)", adminOrgIDs).First(&account)
+
+		if account.Id == 0 {
+			// First login of this organization: verify on My that it is
+			// an active reseller, then create the linked company account
+			// (the modern equivalent of the old "Get credentials" action)
+			newAccount, errCode := provisionCompanyAccount(adminOrgIDs[0])
+			if errCode != "" {
+				oidcFail(c, stateData.PairID, errCode)
+				return
+			}
+			account = *newAccount
+		}
+
+		// The linked account must be a company (reseller) account
+		if account.Type != "reseller" {
+			oidcFail(c, stateData.PairID, "role_mismatch")
+			return
+		}
+	}
+
+	// Create authorization token for the session (256 random bits, same
+	// length as the sha256-hex tokens issued by the password login)
+	tokenBytes := make([]byte, 32)
+	if _, err := rand.Read(tokenBytes); err != nil {
+		oidcFail(c, stateData.PairID, "code_generation_failed")
+		return
+	}
+	authToken := fmt.Sprintf("%x", tokenBytes)
+
+	// Set expiration date
+	expires := time.Now().UTC().AddDate(0, 0, configuration.Config.TokenExpiresDays)
+
+	// The company account is shared by all org admins: record which My
+	// user actually logged in for auditing; device pairings also record
+	// which unit holds the token, so it can be told apart from browser
+	// sessions and revoked selectively
+	description := fmt.Sprintf("OIDC login by %s (%s)", email, sub)
+	if stateData.PairID != "" {
+		description = fmt.Sprintf("OIDC device pairing by %s (%s), unit: %s",
+			email, sub, pairingStore.PendingUnitName(stateData.PairID))
+	}
+
+	accessToken := models.AccessToken{
+		AccountId:   account.Id,
+		Token:       authToken,
+		Role:        account.Type,
+		Type:        "oidc",
+		Expires:     expires,
+		ACLs:        "full",
+		Description: description,
+	}
+
+	db.Save(&accessToken)
+
+	// Device pairing flow: hand the token to the unit that started the
+	// pairing (picked up via /auth/oidc/device/poll) instead of the SPA;
+	// the popup only tells the user the window can be closed, the outcome
+	// summary lives in the unit UI
+	if stateData.PairID != "" {
+		unitName, ok := pairingStore.Complete(stateData.PairID, authToken, expires, account.Id, account.Name, email)
+		if !ok {
+			// the pairing expired while the user was logging in: nobody
+			// can ever pick this token up, drop it
+			db.Delete(&accessToken)
+			pairingResultPage(c, false, "Pairing expired",
+				"The pairing request expired, restart it from your unit.", nil)
+			return
+		}
+		pairingResultPage(c, true, "Unit linked",
+			"The unit has been authenticated on the hotspot manager.", []pairingDetail{
+				{Label: "Account", Value: account.Name},
+				{Label: "Logged in as", Value: email},
+				{Label: "Unit", Value: unitName},
+			})
+		return
+	}
+
+	// Generate a cryptographically secure temporary one-time code (expires in 2 minutes)
+	codeBytes := make([]byte, 16)
+	if _, err := rand.Read(codeBytes); err != nil {
+		oidcFail(c, stateData.PairID, "code_generation_failed")
+		return
+	}
+	tempCode := fmt.Sprintf("%x", codeBytes)
+	codeExpiration := time.Now().Add(2 * time.Minute)
+
+	// Store the code with token and account information
+	codeStore.StoreCode(tempCode, authToken, account.Id, codeExpiration)
+
+	// Redirect to frontend with temporary code instead of token
+	callbackURL := fmt.Sprintf("%s/#/login/callback?code=%s",
+		config.OIDC.FrontendURL,
+		tempCode,
+	)
+
+	c.Redirect(http.StatusTemporaryRedirect, callbackURL)
+}
+
+// OIDCExchange exchanges a temporary code for authentication token
+func OIDCExchange(c *gin.Context) {
+	// Get the temporary code from request
+	var request struct {
+		Code string `json:"code" binding:"required"`
+	}
+
+	if err := c.ShouldBindJSON(&request); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request", "error": err.Error()})
+		return
+	}
+
+	// Validate and retrieve the code data
+	codeData, valid := codeStore.ValidateAndRemoveCode(request.Code)
+	if !valid {
+		c.JSON(http.StatusUnauthorized, gin.H{"message": "Invalid or expired code"})
+		return
+	}
+
+	// Get account information
+	db := database.Instance()
+	var account models.Account
+	if err := db.Where("id = ?", codeData.AccountID).First(&account).Error; err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Account not found"})
+		return
+	}
+
+	// Get token expiration from database
+	var accessToken models.AccessToken
+	if err := db.Where("token = ?", codeData.Token).First(&accessToken).Error; err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Token not found"})
+		return
+	}
+
+	// Load the subscription like the password login does
+	var subscription models.Subscription
+	db.Set("gorm:auto_preload", true)
+	if account.Type == "reseller" {
+		db.Preload("SubscriptionPlan").Where("account_id = ?", account.Id).First(&subscription)
+	} else {
+		db.Preload("SubscriptionPlan").Where("account_id = ?", account.CreatorId).First(&subscription)
+	}
+	subscription.Expired = subscription.ValidUntil.Before(time.Now().UTC())
+
+	// Return the token information
+	c.JSON(http.StatusOK, gin.H{
+		"token":        codeData.Token,
+		"expires":      accessToken.Expires.Unix(),
+		"id":           account.Id,
+		"account_type": account.Type,
+		"subscription": subscription,
+	})
+}
diff --git a/sun/sun-api/methods/device_pairing.go b/sun/sun-api/methods/device_pairing.go
new file mode 100644
index 000000000..6f8111ba6
--- /dev/null
+++ b/sun/sun-api/methods/device_pairing.go
@@ -0,0 +1,415 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * This file is part of Icaro project.
+ *
+ * Icaro 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 any later version.
+ *
+ * Icaro 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 Icaro.  If not, see COPYING.
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package methods
+
+import (
+	"fmt"
+	"html"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/nethesis/icaro/sun/sun-api/configuration"
+)
+
+// Device pairing lets a headless client (a NethSecurity unit) obtain a
+// session token through the browser OIDC login without sharing an origin
+// with the frontend:
+//
+//  1. the unit calls POST /auth/oidc/device/start and receives a public
+//     pair_id (put in the browser URL) and a secret device_code (never
+//     shown to the browser)
+//  2. the unit opens the returned verification_url in the user's browser:
+//     it is the normal /auth/oidc/login flow carrying ?pair=<pair_id>
+//  3. the OIDC callback, instead of handing the session to the SPA,
+//     attaches the freshly minted token to the pairing
+//  4. the unit polls POST /auth/oidc/device/poll with the device_code and
+//     receives the token once the user has completed the login
+//
+// The token is released only to the holder of the device_code, so knowing
+// the pair_id (visible in the URL) is never enough to steal a session.
+
+const (
+	// how long a pairing stays valid waiting for the user to log in
+	devicePairingTTL = 10 * time.Minute
+
+	// suggested client poll interval, returned by /device/start
+	devicePairingPollSeconds = 2
+
+	// same anti-flood bound used for pending OIDC states
+	maxPendingPairings = 10000
+)
+
+type pairingStatus int
+
+const (
+	pairingPending pairingStatus = iota
+	pairingReady
+	pairingFailed
+)
+
+// PairingData tracks one device pairing attempt from creation to the
+// one-shot token pickup
+type PairingData struct {
+	DeviceCode  string
+	UnitName    string
+	Status      pairingStatus
+	Expires     time.Time
+	Token       string
+	TokenExpiry time.Time
+	AccountID   int
+	AccountName string
+	LoggedBy    string
+	ErrorCode   string
+}
+
+// PairingStore manages pending device pairings, indexed both by pair_id
+// (used by the browser flow) and by device_code (used by the polling unit)
+type PairingStore struct {
+	mu       sync.Mutex
+	byPairID map[string]*PairingData
+	byDevice map[string]string // device_code -> pair_id
+}
+
+var pairingStore = &PairingStore{
+	byPairID: make(map[string]*PairingData),
+	byDevice: make(map[string]string),
+}
+
+// StartPairing registers a new pending pairing. Returns false if the store
+// is full.
+func (p *PairingStore) StartPairing(pairID string, deviceCode string, unitName string) bool {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	if len(p.byPairID) >= maxPendingPairings {
+		now := time.Now()
+		for id, data := range p.byPairID {
+			if now.After(data.Expires) {
+				delete(p.byDevice, data.DeviceCode)
+				delete(p.byPairID, id)
+			}
+		}
+		if len(p.byPairID) >= maxPendingPairings {
+			return false
+		}
+	}
+
+	p.byPairID[pairID] = &PairingData{
+		DeviceCode: deviceCode,
+		UnitName:   unitName,
+		Status:     pairingPending,
+		Expires:    time.Now().Add(devicePairingTTL),
+	}
+	p.byDevice[deviceCode] = pairID
+	return true
+}
+
+// IsPending tells whether a pairing exists and is still waiting for a
+// login: the OIDC login entry point uses it to reject unknown or already
+// consumed pair ids before starting a flow
+func (p *PairingStore) IsPending(pairID string) bool {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	data, exists := p.byPairID[pairID]
+	return exists && data.Status == pairingPending && time.Now().Before(data.Expires)
+}
+
+// PendingUnitName returns the unit name declared for a pending pairing
+// (empty if the pairing is unknown or no longer pending)
+func (p *PairingStore) PendingUnitName(pairID string) string {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	if data, exists := p.byPairID[pairID]; exists && data.Status == pairingPending {
+		return data.UnitName
+	}
+	return ""
+}
+
+// Complete attaches the minted session token to a pending pairing and
+// returns the unit name declared when the pairing was started
+func (p *PairingStore) Complete(pairID string, token string, tokenExpiry time.Time, accountID int, accountName string, loggedBy string) (string, bool) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	data, exists := p.byPairID[pairID]
+	if !exists || data.Status != pairingPending || time.Now().After(data.Expires) {
+		return "", false
+	}
+
+	data.Status = pairingReady
+	data.Token = token
+	data.TokenExpiry = tokenExpiry
+	data.AccountID = accountID
+	data.AccountName = accountName
+	data.LoggedBy = loggedBy
+	return data.UnitName, true
+}
+
+// Fail marks a pending pairing as failed so the polling unit can report
+// the reason instead of timing out
+func (p *PairingStore) Fail(pairID string, errorCode string) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	data, exists := p.byPairID[pairID]
+	if !exists || data.Status != pairingPending {
+		return
+	}
+
+	data.Status = pairingFailed
+	data.ErrorCode = errorCode
+}
+
+// Poll returns the current state of a pairing looked up by device_code.
+// Terminal states (ready, failed) are one-shot: the entry is removed as
+// it is returned.
+func (p *PairingStore) Poll(deviceCode string) (PairingData, pairingStatus, bool) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	pairID, exists := p.byDevice[deviceCode]
+	if !exists {
+		return PairingData{}, pairingPending, false
+	}
+	data := p.byPairID[pairID]
+
+	if time.Now().After(data.Expires) {
+		delete(p.byDevice, deviceCode)
+		delete(p.byPairID, pairID)
+		return PairingData{}, pairingPending, false
+	}
+
+	if data.Status == pairingPending {
+		return PairingData{}, pairingPending, true
+	}
+
+	// terminal state: hand it out once and forget the pairing
+	delete(p.byDevice, deviceCode)
+	delete(p.byPairID, pairID)
+	return *data, data.Status, true
+}
+
+// CleanupExpiredPairings removes expired pairings (called by the periodic
+// cleanup routine together with states and codes)
+func (p *PairingStore) CleanupExpiredPairings() {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	now := time.Now()
+	for id, data := range p.byPairID {
+		if now.After(data.Expires) {
+			delete(p.byDevice, data.DeviceCode)
+			delete(p.byPairID, id)
+		}
+	}
+}
+
+// oidcLoginURL derives the public /auth/oidc/login URL from the configured
+// callback redirect URI (same host, sibling path)
+func oidcLoginURL() string {
+	return strings.TrimSuffix(configuration.Config.OIDC.RedirectURI, "/callback") + "/login"
+}
+
+// OIDCDeviceStart creates a new device pairing and returns the URL the
+// unit must open in the user's browser plus the secret device_code used
+// to poll for the token. The optional unit_name is echoed back to the
+// user on the pairing result page.
+func OIDCDeviceStart(c *gin.Context) {
+	config := configuration.Config
+
+	if err := validateOIDCConfig(config); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "OIDC configuration error"})
+		return
+	}
+
+	var request struct {
+		UnitName string `json:"unit_name"`
+	}
+	// the body is optional: an empty or invalid one just means no unit name
+	_ = c.ShouldBindJSON(&request)
+	if len(request.UnitName) > 128 {
+		request.UnitName = request.UnitName[:128]
+	}
+
+	deviceCode, err := generateSecureRandomString()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate device code"})
+		return
+	}
+	pairID, err := generateSecureRandomString()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to generate pair id"})
+		return
+	}
+
+	if !pairingStore.StartPairing(pairID, deviceCode, request.UnitName) {
+		c.JSON(http.StatusServiceUnavailable, gin.H{"message": "Too many pending pairing attempts, retry later"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"device_code":      deviceCode,
+		"pair_id":          pairID,
+		"verification_url": fmt.Sprintf("%s?pair=%s", oidcLoginURL(), pairID),
+		"expires_in":       int(devicePairingTTL.Seconds()),
+		"interval":         devicePairingPollSeconds,
+	})
+}
+
+// OIDCDevicePoll returns the pairing outcome to the unit holding the
+// device_code: pending while the user logs in, then exactly once the
+// token (ready) or the error (failed)
+func OIDCDevicePoll(c *gin.Context) {
+	var request struct {
+		DeviceCode string `json:"device_code" binding:"required"`
+	}
+
+	if err := c.ShouldBindJSON(&request); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid request"})
+		return
+	}
+
+	data, status, found := pairingStore.Poll(request.DeviceCode)
+	if !found {
+		c.JSON(http.StatusOK, gin.H{"status": "expired"})
+		return
+	}
+
+	switch status {
+	case pairingReady:
+		c.JSON(http.StatusOK, gin.H{
+			"status":       "ready",
+			"token":        data.Token,
+			"expires":      data.TokenExpiry.Unix(),
+			"account_id":   data.AccountID,
+			"account_name": data.AccountName,
+			"logged_by":    data.LoggedBy,
+		})
+	case pairingFailed:
+		c.JSON(http.StatusOK, gin.H{
+			"status": "failed",
+			"error":  data.ErrorCode,
+		})
+	default:
+		c.JSON(http.StatusOK, gin.H{"status": "pending"})
+	}
+}
+
+// pairingDetail is a label/value row shown on the pairing result page
+type pairingDetail struct {
+	Label string
+	Value string
+}
+
+// pairingResultPage renders the page shown in the popup at the end of a
+// pairing flow. It is styled after the NethSecurity UI (Tailwind gray
+// palette, cyan primary) so the whole pairing feels like a single product;
+// the outcome summary also lives in the unit UI that initiated the flow.
+func pairingResultPage(c *gin.Context, success bool, title string, message string, details []pairingDetail) {
+	icon := `<svg class="icon ok" viewBox="0 0 512 512" fill="currentColor"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`
+	if !success {
+		icon = `<svg class="icon err" viewBox="0 0 512 512" fill="currentColor"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`
+	}
+
+	rows := ""
+	for _, d := range details {
+		if d.Value == "" {
+			continue
+		}
+		rows += fmt.Sprintf(`<div class="row"><span class="label">%s</span><span class="value">%s</span></div>`,
+			html.EscapeString(d.Label), html.EscapeString(d.Value))
+	}
+	if rows != "" {
+		rows = `<div class="details">` + rows + `</div>`
+	}
+
+	page := fmt.Sprintf(`<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>%s</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
+<style>
+  :root {
+    --gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb; --gray-400: #9ca3af;
+    --gray-500: #6b7280; --gray-600: #4b5563; --gray-700: #374151; --gray-800: #1f2937;
+    --gray-900: #111827; --gray-950: #030712;
+    --green-500: #22c55e; --green-600: #16a34a; --red-500: #ef4444; --red-600: #dc2626;
+  }
+  * { box-sizing: border-box; }
+  body {
+    font-family: "Poppins", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    display: flex; align-items: center; justify-content: center; min-height: 100vh;
+    margin: 0; padding: 1.5rem; background: var(--gray-50); color: var(--gray-700);
+  }
+  .card {
+    background: #fff; border: 1px solid var(--gray-200); border-radius: .5rem;
+    padding: 2rem 2.5rem; width: 100%%; max-width: 34rem;
+    box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+    text-align: center;
+  }
+  .icon { width: 3rem; height: 3rem; margin-bottom: 1rem; }
+  .icon.ok { color: var(--green-600); }
+  .icon.err { color: var(--red-600); }
+  h1 { font-size: 1.125rem; font-weight: 600; color: var(--gray-900); margin: 0 0 .5rem; }
+  .message { font-size: .875rem; font-weight: 400; color: var(--gray-500); margin: 0 0 1.25rem; }
+  .details { text-align: left; margin-bottom: 1.25rem; font-size: .875rem; }
+  .row { display: flex; justify-content: space-between; gap: 1rem; padding: .75rem 0; }
+  .row + .row { border-top: 1px solid var(--gray-200); }
+  .label { color: var(--gray-500); white-space: nowrap; }
+  .value { color: var(--gray-900); font-weight: 500; overflow-wrap: anywhere; text-align: right; }
+  .hint { font-size: .875rem; font-weight: 400; color: var(--gray-500); margin: 0; }
+  @media (prefers-color-scheme: dark) {
+    body { background: var(--gray-950); color: var(--gray-200); }
+    .card { background: var(--gray-900); border-color: var(--gray-700); }
+    h1 { color: var(--gray-50); }
+    .message, .hint, .label { color: var(--gray-400); }
+    .row + .row { border-color: var(--gray-700); }
+    .value { color: var(--gray-50); }
+    .icon.ok { color: var(--green-500); }
+    .icon.err { color: var(--red-500); }
+  }
+</style>
+</head>
+<body>
+<div class="card">
+%s
+<h1>%s</h1>
+<p class="message">%s</p>
+%s
+<p class="hint">You can close this window and go back to your unit.</p>
+</div>
+</body>
+</html>`, html.EscapeString(title), icon, html.EscapeString(title), html.EscapeString(message), rows)
+
+	c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
+}
diff --git a/sun/sun-api/methods/userinfo.go b/sun/sun-api/methods/userinfo.go
new file mode 100644
index 000000000..f9e3419b2
--- /dev/null
+++ b/sun/sun-api/methods/userinfo.go
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * This file is part of Icaro project.
+ *
+ * Icaro 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 any later version.
+ *
+ * Icaro 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 Icaro.  If not, see COPYING.
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package methods
+
+import (
+	"log"
+	"net/http"
+	"strings"
+
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/gin-gonic/gin"
+
+	"github.com/nethesis/icaro/sun/sun-api/configuration"
+	"github.com/nethesis/icaro/sun/sun-api/database"
+	"github.com/nethesis/icaro/sun/sun-api/models"
+)
+
+// emptyUserInfo answers 200 with an empty widget: for the My dashboard a
+// missing/invalid/unlinked identity simply means "nothing to show", and a
+// non-2xx status would surface an error banner in the UI
+func emptyUserInfo(c *gin.Context, msg string) {
+	c.JSON(http.StatusOK, gin.H{
+		"status": "empty",
+		"msg":    msg,
+		"widget": gin.H{"items": []gin.H{}},
+	})
+}
+
+// UserInfo returns aggregate statistics for the company (reseller) of the
+// authenticated My user. The My dashboard widget calls it cross-origin with
+// the user's Logto ID token as bearer: the token is verified against the
+// same issuer used for the OIDC login, and the company is resolved through
+// the organization claims exactly like the login flow (never by email).
+func UserInfo(c *gin.Context) {
+	config := configuration.Config
+
+	if config.OIDC.Issuer == "" {
+		emptyUserInfo(c, "OIDC not configured")
+		return
+	}
+
+	// Bearer token from the Authorization header
+	authParts := strings.SplitN(c.GetHeader("Authorization"), " ", 2)
+	if len(authParts) != 2 || !strings.EqualFold(authParts[0], "Bearer") {
+		emptyUserInfo(c, "Missing or invalid authorization header")
+		return
+	}
+
+	provider, err := getOIDCProvider(config.OIDC.Issuer)
+	if err != nil {
+		log.Println("userinfo: OIDC provider initialization failed:", err)
+		emptyUserInfo(c, "Cannot retrieve OIDC provider")
+		return
+	}
+
+	// The caller is My's own application, not our third-party client: the
+	// audience is checked manually against the configured value
+	verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
+	idToken, err := verifier.Verify(c.Request.Context(), authParts[1])
+	if err != nil {
+		log.Println("userinfo denied: invalid token:", err)
+		emptyUserInfo(c, "Invalid token")
+		return
+	}
+
+	if config.OIDC.UserinfoAudience != "" {
+		audOk := false
+		for _, aud := range idToken.Audience {
+			if aud == config.OIDC.UserinfoAudience {
+				audOk = true
+				break
+			}
+		}
+		if !audOk {
+			log.Println("userinfo denied: audience mismatch:", idToken.Audience)
+			emptyUserInfo(c, "Invalid token: audience mismatch")
+			return
+		}
+	}
+
+	var claims map[string]interface{}
+	if err := idToken.Claims(&claims); err != nil {
+		emptyUserInfo(c, "Invalid token: claims extraction failed")
+		return
+	}
+
+	// The company is the organization where the user holds the reseller
+	// organization-role, mapped to the account through logto_org_id
+	orgIDs := extractOrgIDsWithRole(claims, config.OIDC.OrgResellerRole)
+	if len(orgIDs) == 0 {
+		log.Printf("userinfo denied for %v: no organization with role %q (organization_roles=%v)",
+			claims["email"], config.OIDC.OrgResellerRole, claims["organization_roles"])
+		emptyUserInfo(c, "No reseller organization in token")
+		return
+	}
+
+	db := database.Instance()
+	var account models.Account
+	db.Where("logto_org_id IN (?)", orgIDs).First(&account)
+	if account.Id == 0 || account.Type != "reseller" {
+		log.Printf("userinfo denied for %v: no company account linked to organizations %v", claims["email"], orgIDs)
+		emptyUserInfo(c, "No company account linked to the organization")
+		return
+	}
+
+	// Aggregate counters over the company's hotspots
+	countByHotspot := func(table string) int {
+		var n int
+		db.Table(table).
+			Joins("JOIN hotspots ON hotspots.id = "+table+".hotspot_id").
+			Where("hotspots.account_id = ?", account.Id).
+			Count(&n)
+		return n
+	}
+
+	var hotspots, managers int
+	db.Table("hotspots").Where("account_id = ?", account.Id).Count(&hotspots)
+	db.Table("accounts").Where("creator_id = ? AND type IN (?)", account.Id, []string{"desk", "customer"}).Count(&managers)
+	units := countByHotspot("units")
+	guests := countByHotspot("users")
+	devices := countByHotspot("devices")
+	sessions := countByHotspot("sessions")
+
+	var sms models.AccountSmsCount
+	db.Where("account_id = ?", account.Id).First(&sms)
+	smsRemaining := sms.SmsMaxCount - sms.SmsCount
+	if smsRemaining < 0 {
+		smsRemaining = 0
+	}
+
+	// SMS tone: warn the reseller when the remaining quota gets thin
+	smsTone := "neutral"
+	if sms.SmsMaxCount > 0 {
+		switch {
+		case smsRemaining*100 <= sms.SmsMaxCount*10:
+			smsTone = "danger"
+		case smsRemaining*100 <= sms.SmsMaxCount*25:
+			smsTone = "warning"
+		}
+	}
+
+	// Generic widget contract consumed by the My dashboard: My renders
+	// `widget.items` without knowing anything about NethSpot
+	base := strings.TrimRight(config.OIDC.FrontendURL, "/")
+	widgetItems := []gin.H{
+		{"label": "Hotspot", "value": hotspots, "tone": "neutral", "link": base + "/#/hotspots"},
+		{"label": "Unit", "value": units, "tone": "neutral", "link": base + "/#/units"},
+		{"label": "Utenti", "value": guests, "tone": "neutral", "link": base + "/#/users"},
+		{"label": "SMS residui", "value": smsRemaining, "tone": smsTone, "link": base + "/"},
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"status":  "ok",
+		"msg":     "Company " + account.Name + " authenticated",
+		"company": account.Name,
+		// Raw counters, for consumers that want to build their own view
+		"hotspots": hotspots,
+		"units":    units,
+		"users":    guests,
+		"devices":  devices,
+		"sessions": sessions,
+		"managers": managers,
+		"sms": gin.H{
+			"count":     sms.SmsCount,
+			"max":       sms.SmsMaxCount,
+			"remaining": smsRemaining,
+		},
+		// Generic contract rendered by the My dashboard
+		"widget": gin.H{
+			"items": widgetItems,
+		},
+	})
+}
diff --git a/sun/sun-api/methods/users.go b/sun/sun-api/methods/users.go
index fb4c72242..4365e79a8 100644
--- a/sun/sun-api/methods/users.go
+++ b/sun/sun-api/methods/users.go
@@ -347,15 +347,16 @@ func GetUsersExpired(c *gin.Context) {
 		buf := bytes.NewBuffer(b)
 		w := csv.NewWriter(buf)
 
-		// extract headers
+		// extract headers - use User model structure (without UserId)
 		var headers []string
 		if len(users) > 0 {
+			// User model doesn't have UserId, use it as-is
 			headers = structs.Names(users[0])
+		} else if len(userHistories) > 0 {
+			// UserHistory has UserId as second field (index 1), remove it to match User structure
+			headersWithUserId := structs.Names(userHistories[0])
+			headers = utils.RemoveIndex(headersWithUserId, 1)
 		}
-		if len(userHistories) > 0 {
-			headers = structs.Names(userHistories[0])
-		}
-		headers = utils.RemoveIndex(headers, 1)
 		// write headers to writer
 		if err := w.Write(headers); err != nil {
 			// write failed
diff --git a/sun/sun-api/models/account.go b/sun/sun-api/models/account.go
index 4fe37b9b9..72610c0e4 100644
--- a/sun/sun-api/models/account.go
+++ b/sun/sun-api/models/account.go
@@ -28,6 +28,7 @@ type Account struct {
 	Id             int       `db:"id" json:"id"`
 	CreatorId      int       `db:"creator_id" json:"creator_id"`
 	Uuid           string    `db:"uuid" json:"uuid"`
+	LogtoOrgId     *string   `db:"logto_org_id" json:"logto_org_id"` // My/Logto organization linked to this company account (nil = classic account)
 	Type           string    `db:"type" json:"type"`
 	Name           string    `db:"name" json:"name"`
 	Username       string    `db:"username" json:"username"`
diff --git a/sun/sun-ui/src/App.vue b/sun/sun-ui/src/App.vue
index 61b0cc946..e745b17cb 100644
--- a/sun/sun-ui/src/App.vue
+++ b/sun/sun-ui/src/App.vue
@@ -7,7 +7,7 @@
           <img src="/static/logo-light.png" alt=" logo">
         </span>
         <div class="container">
-          <div class="row">
+          <div class="row login-button">
             <div class="col-sm-12 col-brand">
               <div id="brand">
                 <h1>{{appName}}</h1>
@@ -49,20 +49,37 @@
                   </div>
                 </div>
                 <div class="form-group">
-                  <div class="col-xs-8 col-sm-offset-2 col-sm-6 col-md-offset-2 col-md-6">
-                    <span class="help-block"></span>
+                  <div class="col-sm-offset-2 col-sm-10 col-md-offset-2 col-md-10 submit">
+                    <button type="submit" class="btn btn-primary btn-lg btn-block" style="border-radius: 4px;" tabindex="4">Log In</button>
                   </div>
-                  <div class="col-xs-4 col-sm-4 col-md-4 submit">
-                    <button type="submit" class="btn btn-primary btn-lg" tabindex="4">Log In</button>
+                </div>
+
+                <!-- OIDC Error Alert -->
+                <div v-if="oidcError" class="form-group">
+                  <div class="col-sm-offset-2 col-sm-10 col-md-offset-2 col-md-10">
+                    <div class="alert alert-danger">
+                      <span class="pficon pficon-error-circle-o"></span>
+                      <strong>{{ $t("login.access_denied") }}</strong>
+                      {{ getOIDCErrorMessage() }}
+                    </div>
+                  </div>
+                </div>
+
+                <!-- Separator and OIDC Login -->
+                <div v-if="oidcConfig && oidcConfig.login_button" class="form-group oidc-login-section">
+                  <div class="col-sm-offset-2 col-sm-10 col-md-offset-2 col-md-10">
+                    <div class="login-separator">
+                    </div>
+                    <button type="button" class="btn btn-nethesis btn-lg btn-block" v-on:click="doOIDCLogin()" tabindex="5">
+                      <img src="/static/my-nethesis.svg" class="btn-nethesis-icon" alt="" aria-hidden="true">
+                      {{ $t("login.login_with") }} {{oidcConfig.provider_name || 'My Nethesis'}}
+                    </button>
                   </div>
                 </div>
               </form>
             </div>
             <!--/.col-*-->
             <div class="col-sm-5 col-md-6 col-lg-7 details">
-              <p>
-                <strong>{{ $t("login.welcome") }} {{appName}}</strong>
-              </p>
             </div>
             <!--/.col-*-->
           </div>
@@ -90,7 +107,7 @@
               <span style="display:inline-block;padding:2px 3px;">Zara Walker</span>
             </a>
           </div>
-          <div>Copyright &copy; {{currentYear()}} {{companyName}}</div>
+          <div>{{ $t("login.copyright") }} &copy; {{currentYear()}} {{companyName}}</div>
           <div><a href="/privacy" target="blank">Privacy</a></div>
         </div>
         <!--/.container-->
@@ -335,11 +352,15 @@
       <div
         class="container-fluid container-cards-pf container-pf-nav-pf-vertical nav-pf-persistent-secondary"
       >
-        <router-view></router-view>
+        <router-view v-if="isLogged"></router-view>
       </div>
       <!-- end main view -->
     </div>
     <!-- end logged view -->
+
+    <!-- Router view for non-logged users (OIDC callback only) -->
+    <router-view v-if="!isLogged && $route.name === 'LoginCallback'"></router-view>
+
     <!-- modals -->
     <div
       class="modal fade"
@@ -426,6 +447,13 @@ export default {
       info: {}
     };
 
+    // A denied OIDC login must not silently resume a previous session:
+    // drop the stored session so the error is visible on the login page
+    if (new URLSearchParams(window.location.search).get("error")) {
+      this.delete("loggedUser");
+      user.login = null;
+    }
+
     var errors = {
       username: false,
       password: false
@@ -454,10 +482,72 @@ export default {
       errors: errors,
       appName: CONFIG.APP_NAME,
       helpUrl: CONFIG.HELP_URL,
-      companyName: CONFIG.COMPANY_NAME
+      companyName: CONFIG.COMPANY_NAME,
+      CONFIG: CONFIG,
+      oidcConfig: null,
+      oidcError: null
     };
   },
+  mounted() {
+    // Load OIDC configuration
+    this.loadOIDCConfig();
+    // Check for OIDC errors in URL
+    this.checkOIDCError();
+  },
   methods: {
+    loadOIDCConfig() {
+      this.getOIDCConfig(
+        success => {
+          this.oidcConfig = success.body;
+        },
+        error => {
+          console.log("Failed to load OIDC config:", error);
+          this.oidcConfig = { enabled: false };
+        }
+      );
+    },
+    checkOIDCError() {
+      // Check URL parameters for OIDC errors
+      const urlParams = new URLSearchParams(window.location.search);
+      const error = urlParams.get('error');
+      if (error) {
+        this.oidcError = error;
+        // Clean URL after showing error
+        const url = new URL(window.location);
+        url.searchParams.delete('error');
+        window.history.replaceState({}, document.title, url.pathname + url.hash);
+      }
+    },
+    getOIDCErrorMessage() {
+      switch (this.oidcError) {
+        case 'unauthorized_role':
+          return this.$t('login.unauthorized_role_message');
+        case 'missing_code':
+          return this.$t('login.missing_code_message');
+        case 'invalid_state':
+        case 'invalid_nonce':
+          return this.$t('login.invalid_state_message');
+        case 'token_exchange_failed':
+          return this.$t('login.token_exchange_failed_message');
+        case 'email_not_verified':
+          return this.$t('login.email_not_verified_message');
+        case 'account_not_found':
+          return this.$t('login.account_not_found_message');
+        case 'role_mismatch':
+          return this.$t('login.role_mismatch_message');
+        case 'org_suspended':
+          return this.$t('login.org_suspended_message');
+        case 'provisioning_failed':
+        case 'account_creation_failed':
+          return this.$t('login.account_creation_failed_message');
+        case 'no_admin_account_found':
+          return this.$t('login.no_admin_account_found_message');
+        case 'exchange_failed':
+          return this.$t('login.exchange_failed_message');
+        default:
+          return this.$t('login.generic_oidc_error_message');
+      }
+    },
     currentYear() {
       return new Date().getFullYear();
     },
@@ -549,6 +639,9 @@ export default {
         }
       );
     },
+    doOIDCLogin() {
+      this.execOIDCLogin();
+    },
     initGraphics() {
       $("body").addClass("logged");
       $("body").removeClass("not-logged");
diff --git a/sun/sun-ui/src/i18n/locale-en.json b/sun/sun-ui/src/i18n/locale-en.json
index 177566ec4..21c3afb86 100644
--- a/sun/sun-ui/src/i18n/locale-en.json
+++ b/sun/sun-ui/src/i18n/locale-en.json
@@ -2,10 +2,26 @@
   "en": {
     "login": {
       "welcome": "Welcome to",
-      "insert_username": "Insert you username",
+      "insert_username": "Insert your username",
       "insert_password": "Insert your password",
       "user_error": "Invalid username",
-      "password_error": "Invalid password"
+      "password_error": "Invalid password",
+      "or": "or",
+      "login_with": "Login with",
+      "copyright": "Copyright",
+      "access_denied": "Access Denied",
+      "unauthorized_role_message": "Your role does not have permission to access this system.",
+      "missing_code_message": "Authentication failed: missing authorization code.",
+      "invalid_state_message": "Authentication failed: invalid security token.",
+      "token_exchange_failed_message": "Authentication failed: unable to exchange tokens.",
+      "email_not_verified_message": "Authentication failed: your email address is not verified.",
+      "account_not_found_message": "Access denied: no account matches your user, contact your administrator.",
+      "role_mismatch_message": "Access denied: your role does not match your account type, contact your administrator.",
+      "org_suspended_message": "Access denied: your organization is suspended, contact your administrator.",
+      "account_creation_failed_message": "Authentication failed: unable to activate your company account, please retry or contact support.",
+      "no_admin_account_found_message": "Authentication failed: admin account not found.",
+      "exchange_failed_message": "Authentication failed: secure token exchange failed.",
+      "generic_oidc_error_message": "Authentication failed: please try again or contact support."
     },
     "dashboard": {
       "preferences": "Preferences",
diff --git a/sun/sun-ui/src/i18n/locale-it.json b/sun/sun-ui/src/i18n/locale-it.json
index 26f4e85a8..0224faba0 100644
--- a/sun/sun-ui/src/i18n/locale-it.json
+++ b/sun/sun-ui/src/i18n/locale-it.json
@@ -5,7 +5,23 @@
       "insert_username": "Inserire  il tuo utente",
       "insert_password": "Inserisci la tua password",
       "user_error": "Utente non valido",
-      "password_error": "Password non valida"
+      "password_error": "Password non valida",
+      "or": "oppure",
+      "login_with": "Accedi con",
+      "copyright": "Copyright",
+      "access_denied": "Accesso Negato",
+      "unauthorized_role_message": "Il tuo ruolo non ha i permessi per accedere a questo sistema.",
+      "missing_code_message": "Autenticazione fallita: codice di autorizzazione mancante.",
+      "invalid_state_message": "Autenticazione fallita: token di sicurezza non valido.",
+      "token_exchange_failed_message": "Autenticazione fallita: impossibile scambiare i token.",
+      "email_not_verified_message": "Autenticazione fallita: il tuo indirizzo email non è verificato.",
+      "account_not_found_message": "Accesso negato: nessun account corrisponde al tuo utente, contatta il tuo amministratore.",
+      "role_mismatch_message": "Accesso negato: il tuo ruolo non corrisponde al tipo del tuo account, contatta il tuo amministratore.",
+      "org_suspended_message": "Accesso negato: la tua organizzazione risulta sospesa, contatta il tuo amministratore.",
+      "account_creation_failed_message": "Autenticazione fallita: impossibile attivare l'account della tua azienda, riprova o contatta il supporto.",
+      "no_admin_account_found_message": "Autenticazione fallita: account amministratore non trovato.",
+      "exchange_failed_message": "Autenticazione fallita: scambio sicuro del token fallito.",
+      "generic_oidc_error_message": "Autenticazione fallita: riprova o contatta il supporto."
     },
     "dashboard": {
       "preferences": "Preferenze",
diff --git a/sun/sun-ui/src/routes/router.js b/sun/sun-ui/src/routes/router.js
index 82c47da7b..99742dbc3 100644
--- a/sun/sun-ui/src/routes/router.js
+++ b/sun/sun-ui/src/routes/router.js
@@ -20,6 +20,12 @@ import Devices from "../components/Devices.vue";
 
 Vue.use(Router);
 
+// Error redirect constants
+const ERROR_ROUTES = {
+  EXCHANGE_FAILED: '/?error=exchange_failed',
+  MISSING_CODE: '/?error=missing_code'
+};
+
 const router = new Router({
   routes: [{
       path: "/",
@@ -29,6 +35,59 @@ const router = new Router({
         roles: ["admin", "reseller", "customer", "desk"]
       }
     },
+    {
+      path: '/login/callback',
+      name: 'LoginCallback',
+      component: {
+        template: '<div>Processing login...</div>',
+        async created() {
+          // Handle OIDC callback with secure code exchange
+          const code = this.$route.query.code;
+
+          if (code) {
+            try {
+              // Exchange the temporary code for token
+              const response = await this.$http.post(
+                this.$root.$options.api_scheme +
+                this.$root.$options.api_host +
+                "/api/auth/oidc/exchange",
+                { code: code }
+              );
+
+              if (response.status === 200) {
+                const data = response.data;
+
+                // Create user object similar to regular login
+                const loggedUser = {
+                  token: data.token,
+                  expires: new Date(parseInt(data.expires) * 1000).toString(),
+                  id: parseInt(data.id),
+                  account_type: data.account_type,
+                  subscription: data.subscription
+                };
+
+                // Save to localStorage
+                localStorage.setItem('loggedUser', JSON.stringify(loggedUser));
+
+                // For successful OIDC login, we need a full page reload to reinitialize the app
+                // This is safe because we're redirecting to our own root path after successful auth
+                window.location.href = '/';
+              } else {
+                // Exchange failed, redirect to login with error
+                this.$router.push(ERROR_ROUTES.EXCHANGE_FAILED);
+              }
+            } catch (error) {
+              console.error('OIDC code exchange failed:', error);
+              // Exchange failed, redirect to login with error
+              this.$router.push(ERROR_ROUTES.EXCHANGE_FAILED);
+            }
+          } else {
+            // No code provided, something went wrong
+            this.$router.push(ERROR_ROUTES.MISSING_CODE);
+          }
+        }
+      }
+    },
     {
       path: "/hotspots",
       name: "Hotspots",
@@ -161,6 +220,12 @@ const router = new Router({
 });
 
 router.beforeEach((to, from, next) => {
+  // Always allow OIDC callback route
+  if (to.name === 'LoginCallback') {
+    next();
+    return;
+  }
+
   var user = JSON.parse(localStorage.getItem("loggedUser"));
   if (user && to.meta.roles && to.meta.roles.indexOf(user.account_type) >= 0) {
     next();
diff --git a/sun/sun-ui/src/services/login.js b/sun/sun-ui/src/services/login.js
index 9b88f5b3f..345e7dc0b 100644
--- a/sun/sun-ui/src/services/login.js
+++ b/sun/sun-ui/src/services/login.js
@@ -60,6 +60,16 @@ var LoginService = {
           }
         )
         .then(success, error);
+    },
+    execOIDCLogin() {
+      window.location.href = this.$root.$options.api_scheme +
+        this.$root.$options.api_host +
+        "/api/auth/oidc/login";
+    },
+    getOIDCConfig(success, error) {
+      this.$http
+        .get(this.$root.$options.api_scheme + this.$root.$options.api_host + "/api/auth/oidc/config")
+        .then(success, error);
     }
   }
 };
diff --git a/sun/sun-ui/src/styles/main.css b/sun/sun-ui/src/styles/main.css
index ede04516b..55c48c4a7 100644
--- a/sun/sun-ui/src/styles/main.css
+++ b/sun/sun-ui/src/styles/main.css
@@ -488,4 +488,75 @@ a {
   float: right;
   margin-right: 170px;
   margin-top: -56px;
+}
+
+/* OIDC Login Styles */
+.login-button {
+  margin-bottom: 30px;
+}
+
+@media screen and (max-width: 767px) {
+  .oidc-login-section .col-sm-offset-2 {
+    display: flow-root;
+  }
+}
+
+.oidc-login-section {
+  margin-top: 15px;
+}
+
+.login-separator {
+  text-align: center;
+  margin: 0px 0 15px 0;
+  position: relative;
+}
+
+.login-separator:before {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 0;
+  right: 0;
+  height: 1px;
+  background: rgba(255, 255, 255, 0.3);
+}
+
+.login-separator span {
+  background: rgba(0, 0, 0, 0.8);
+  color: rgba(255, 255, 255, 0.7);
+  padding: 0 15px;
+  font-size: 12px;
+  text-transform: uppercase;
+  letter-spacing: 1px;
+  position: relative;
+  z-index: 1;
+}
+
+/* Outlined button matching the NethSecurity UI secondary style */
+.btn-nethesis {
+  background: #ffffff !important;
+  border: 1px solid #d1d5db !important;
+  color: #0e7490 !important;
+  font-weight: 600 !important;
+  transition: all 0.3s ease !important;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
+  border-radius: 4px !important;
+}
+
+.btn-nethesis:hover,
+.btn-nethesis:focus {
+  background: #f9fafb !important;
+  color: #155e75 !important;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.btn-nethesis:active {
+  background: #f3f4f6 !important;
+}
+
+.btn-nethesis-icon {
+  width: 20px;
+  height: 20px;
+  margin-right: 8px;
+  vertical-align: -5px;
 }
\ No newline at end of file
diff --git a/sun/sun-ui/static/my-nethesis.svg b/sun/sun-ui/static/my-nethesis.svg
new file mode 100644
index 000000000..befaea222
--- /dev/null
+++ b/sun/sun-ui/static/my-nethesis.svg
@@ -0,0 +1,7 @@
+<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
+  <rect width="48" height="48" rx="10" fill="#0ea5e9" />
+  <g fill="#ffffff" transform="translate(-37.97 11) scale(0.187)">
+    <path d="M300.215 20.5056C300.215 37.3883 283.659 37.3883 283.659 37.3883V0H300.215V20.5056Z" />
+    <path d="M339.648 52.2904C347.588 52.2904 353.5 54.8747 357.7 60.2366C360.933 64.3426 362.574 69.5354 362.574 75.646V122.116C362.574 139.023 379.106 139.023 379.106 139.023V75.1147C379.106 63.5939 375.389 54.1985 368.077 47.1459C360.547 40.045 351.28 36.6154 339.648 36.6154C328.016 36.6154 318.942 39.9967 311.533 47.001C304.004 53.9811 300.215 63.4489 300.215 75.1147V139.023H316.746V75.6702C316.746 69.5596 318.411 64.3909 321.669 60.2608C325.844 54.923 331.732 52.3146 339.672 52.3146H339.648V52.2904Z" />
+  </g>
+</svg>
