From 873b2939faefaa2efbf6e880eb0edbe92cd14e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Sat, 22 Aug 2026 20:10:11 +0200 Subject: [PATCH 1/2] feat(session): stamp the account e-mail into the session at sign-in Downstream services need to key trust decisions on the DOMAIN of the account's e-mail. Today they cannot: SessionData carries only admin / profile / anonymous / id / client_id, so a reader holding a session knows WHO the account is but not what address it authenticated with. Their only alternative is an organisation or profile contact address the requester can edit -- i.e. forge. The licence server is the immediate consumer: it decides "one evaluation per organisation" and refuses free-mail domains on that e-mail domain, and both rules are bypassable while the address is user-editable. Stamping it at sign-in puts the address behind the session's HMAC, which is the same trust the `admin` flag already carries for authorization. No new session API is needed: SessionDataDecorator already exposes a generic `+(kv)`, and every one of these sites already uses it one line below to stamp the profile. All SIX authentication paths stamp it -- AccountServiceEndpoints (x2), AccountService (x2), OAuthServiceEndpoints, OAuthService. A missed path would not fail; the reader would simply fall back to the editable address and be silently wrong, so they are one change. The key lives in AccountSettings as a constant, NOT as a config value: it is a wire contract with those readers, so it must not be re-mappable per deployment. `Account.email` is an Option -- an account registered by phone has none -- so the key is ABSENT rather than empty in that case, letting a reader distinguish "no address" from "an address we lost". Tested in AccountRouteSpec, so it runs across all EIGHT concrete variants (akka-http routes AND tapir endpoints, cookie AND header, one-off AND refreshable): the e-mail is present after sign-in, and absent for a gsm-only account -- the second assertion is what rules out an unconditional `getOrElse("")` stamp. testkit/test: 480 passed. Cross-compiles 2.12 + 2.13. See adr-evaluation-trusted-domain-source (elasticsql planning artifacts). Co-Authored-By: Claude Opus 5 (1M context) --- .../account/config/AccountSettings.scala | 14 ++++++++++++++ .../account/service/AccountService.scala | 8 ++++++++ .../service/AccountServiceEndpoints.scala | 8 ++++++++ .../account/service/OAuthService.scala | 4 ++++ .../service/OAuthServiceEndpoints.scala | 4 ++++ .../account/scalatest/AccountRouteSpec.scala | 18 ++++++++++++++++++ 6 files changed, 56 insertions(+) diff --git a/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala b/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala index 7ef1bb5..fc19298 100644 --- a/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala +++ b/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala @@ -15,6 +15,20 @@ object AccountSettings extends StrictLogging { val Realm: String = config.getString("auth.realm") + /** Session key holding the account's e-mail, stamped at sign-in by every authentication path. + * + * Downstream services derive trust decisions from the e-mail DOMAIN — the licence server keys + * its one-evaluation-per-organisation rule and its free-mail gate on it. Their only other source + * is an organisation contact address the requester can edit, i.e. forge. Putting the address + * here places it behind the session's HMAC, which is the same trust the `admin` flag already + * relies on for authorization. + * + * Not a config value: it is a wire contract with those readers, so it must not be re-mappable + * per deployment. Absent for an account registered by phone (`Account.email` is an `Option`) — + * readers must handle that rather than assume presence. + */ + val SessionEmailKey: String = "email" + val Path: String = config.getString("auth.path") val BaseUrl: String = config.getString("auth.baseUrl") diff --git a/core/src/main/scala/app/softnetwork/account/service/AccountService.scala b/core/src/main/scala/app/softnetwork/account/service/AccountService.scala index 4d67d4a..4ecf48f 100644 --- a/core/src/main/scala/app/softnetwork/account/service/AccountService.scala +++ b/core/src/main/scala/app/softnetwork/account/service/AccountService.scala @@ -182,6 +182,10 @@ trait AccountService[PV <: ProfileView, DV <: AccountDetailsView, AV <: AccountV session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } setSession(sc, st, session) { // create a new anti csrf token setNewCsrfToken(checkHeader) { @@ -224,6 +228,10 @@ trait AccountService[PV <: ProfileView, DV <: AccountDetailsView, AV <: AccountV session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } setSession(sc, st, session) { // create a new anti csrf token setNewCsrfToken(checkHeader) { diff --git a/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala b/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala index 09fb27f..694edc3 100644 --- a/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala @@ -248,6 +248,10 @@ trait AccountServiceEndpoints[SU, SD <: SessionData with SessionDataDecorator[SD session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } Right((account.view.asInstanceOf[AV], Some(session))) case other => Left(resultToApiError(other)) } @@ -313,6 +317,10 @@ trait AccountServiceEndpoints[SU, SD <: SessionData with SessionDataDecorator[SD session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } Right( ( ( diff --git a/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala b/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala index 0591554..e6ba488 100644 --- a/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala +++ b/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala @@ -165,6 +165,10 @@ trait OAuthService[SD <: SessionData with SessionDataDecorator[SD]] session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } setSession(sc, st, session) { // create a new anti csrf token setNewCsrfToken(checkHeader) { diff --git a/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala b/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala index 709221b..804ac99 100644 --- a/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala @@ -222,6 +222,10 @@ trait OAuthServiceEndpoints[SD <: SessionData with SessionDataDecorator[SD]] session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } Right( account.details .map(d => Me(d.firstName, d.lastName, d.email)) diff --git a/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala b/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala index 9a615e4..d854b5b 100644 --- a/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala +++ b/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala @@ -313,6 +313,24 @@ trait AccountRouteSpec[ responseAs[AV].status shouldBe AccountStatus.Active } } + // Downstream services key trust decisions on the DOMAIN of this address — the licence server + // decides one-evaluation-per-organisation and its free-mail gate on it. Their only other source + // is an organisation contact the requester can edit, i.e. forge, so the stamp is a contract + // rather than a convenience. It is asserted at the SESSION because an authentication path that + // forgets it degrades silently: the reader just falls back. + // (The e-mail account is activated by "work with matching email and password" above.) + "stamp the account e-mail into the session" in { + // No trailing signOut(): `signIn` opens with one, and calling it twice leaves stale + // headers that make the NEXT sign-in 403. + signIn(email, password) + extractSession().flatMap(_.get(AccountSettings.SessionEmailKey)) shouldBe Some(email) + } + // `Account.email` is an Option — an account registered by phone has none. The key must then be + // ABSENT rather than empty, so a reader can tell "no address" from "an address we lost". + "leave the e-mail absent for an account that has none" in { + signIn(gsm, password) + extractSession().flatMap(_.get(AccountSettings.SessionEmailKey)) shouldBe None + } "fail with unknown username" in { Post( s"/$RootPath/${AccountSettings.Path}/login", From 527a76369b426d0587b6a7c8dd9bf91708f20832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Sat, 22 Aug 2026 20:15:50 +0200 Subject: [PATCH 2/2] chore: prepare release v0.8.8 Carries the sign-in session e-mail stamp. Downstream train: generic-account-api 0.8.8 -> softpayment -> softclient4es-license-server, which has no direct pin and resolves this transitively via Versions.softPayment. Co-Authored-By: Claude Opus 5 (1M context) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index f1e5071..960da2f 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ ThisBuild / organization := "app.softnetwork" name := "account" -ThisBuild / version := "0.8.7" +ThisBuild / version := "0.8.8" ThisBuild / scalaVersion := scala212