~jan0sch/smederee

Showing details for patch 8e784521934e446e29af5e7b537dfec17fcf6357.
2023-05-22 (Mon), 12:33 PM - Jens Grassel - 8e784521934e446e29af5e7b537dfec17fcf6357

Authentication: Set domain name in cookie to allow sharing across sub domains.

The authentication cookie will now get the domain field filled with the
configured hostname of the hub service. As long as it is set to the top
domain (e.g. smeder.ee) then this will allow sharing the cookie across sub
domains.
Summary of changes
5 files modified with 79 lines added and 25 lines removed
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala with 6 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SessionHelpers.scala with 17 added and 16 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala with 52 added and 7 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/SessionHelpersTest.scala with 3 added and 1 removed lines
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-01-16 09:54:51.101214182 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala	2025-01-16 09:54:51.101214182 +0000
@@ -22,6 +22,7 @@
 import cats.data._
 import cats.effect._
 import cats.syntax.all._
+import de.smederee.html.ExternalUrlConfiguration
 import de.smederee.hub.RequestHelpers.instances.given_RequestHelpers_Request
 import de.smederee.hub.SessionHelpers.instances.toAuthenticationCookie
 import de.smederee.hub._
@@ -59,6 +60,9 @@
   *   A clock to generate nounces for cryptograpic operations.
   * @param authenticationConfig
   *   The configuration for the authentication module.
+  * @param external
+  *   Settings affecting how the service will communicate several information to the "outside world" e.g. if it runs
+  *   behind a reverse proxy.
   * @param repo
   *   The database repository providing needed functionality.
   * @param signAndValidate
@@ -69,6 +73,7 @@
 final class AuthenticationRoutes[F[_]: Async](
     clock: java.time.Clock,
     authenticationConfig: AuthenticationConfiguration,
+    external: ExternalUrlConfiguration,
     repo: AuthenticationRepository[F],
     signAndValidate: SignAndValidate
 ) extends Http4sDsl[F] {
@@ -152,7 +157,7 @@
                       signAndValidate.signToken(session.id.toString)(clock.millis.toString)
                     )
                     response <- SeeOther(Location(Uri(path = Uri.Path.Root)))
-                      .map(_.addCookie(token.toAuthenticationCookie(None)(secure = true)))
+                      .map(_.addCookie(token.toAuthenticationCookie(external.host.toString.some)(None)(secure = true)))
                   } yield response
               }
             } yield response
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-16 09:54:51.101214182 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-16 09:54:51.101214182 +0000
@@ -214,6 +214,7 @@
       authenticationRoutes = new AuthenticationRoutes[IO](
         cryptoClock,
         hubConfiguration.service.authentication,
+        hubConfiguration.service.external,
         authenticationRepo,
         signAndValidate
       )
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SessionHelpers.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SessionHelpers.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SessionHelpers.scala	2025-01-16 09:54:51.101214182 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SessionHelpers.scala	2025-01-16 09:54:51.101214182 +0000
@@ -51,6 +51,10 @@
         * cookie will be using a strict same-site policy, be http-only and respect the given values for expiration and
         * the secure flag.
         *
+        * @param domain
+        *   An optional domain that shall be set for the cookie. If it is not set then a "host only" cookie will be
+        *   created which is in most cases what you want. Setting a specific domain can be necessary if you want to
+        *   share cookies between a domain and possible existing sub domains.
         * @param expires
         *   A duration in which the cookie shall expire. It is optional resulting in the cookie being a non-persistent
         *   "session cookie" if not set.
@@ -60,23 +64,20 @@
         * @return
         *   A response cookie ready to be used as an authentication cookie.
         */
-      def toAuthenticationCookie(expires: Option[FiniteDuration])(secure: Boolean): ResponseCookie = {
-        val cookie =
-          ResponseCookie(
-            name = Constants.authenticationCookieName.toString,
-            content = signedToken.toString,
-            sameSite = Option(SameSite.Strict),
-            secure = secure,
-            httpOnly = true
-          )
-        expires.fold(cookie)(duration =>
-          cookie.copy(expires =
-            HttpDate
-              .fromZonedDateTime(ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(duration.toSeconds))
-              .toOption
-          )
+      def toAuthenticationCookie(
+          domain: Option[String]
+      )(expires: Option[FiniteDuration])(secure: Boolean): ResponseCookie =
+        ResponseCookie(
+          name = Constants.authenticationCookieName.toString,
+          content = signedToken.toString,
+          expires = expires.flatMap(duration =>
+            HttpDate.fromZonedDateTime(ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(duration.toSeconds)).toOption
+          ),
+          domain = domain,
+          sameSite = Option(SameSite.Strict),
+          secure = secure,
+          httpOnly = true
         )
-      }
     }
   }
 }
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala	2025-01-16 09:54:51.101214182 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala	2025-01-16 09:54:51.101214182 +0000
@@ -48,11 +48,14 @@
 
     val clock                = java.time.Clock.systemUTC
     val authenticationConfig = configuration.service.authentication
+    val externalConfig       = configuration.service.external
     val repo                 = new TestAuthenticationRepository[IO](List.empty, List.empty)
     val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
 
     def service: HttpRoutes[IO] =
-      Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+      Router(
+        "/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, externalConfig, repo, signAndValidate).routes
+      )
 
     def request = Request[IO](method = Method.GET, uri = loginPath)
 
@@ -73,11 +76,14 @@
   test("POST /login must return 415 - Unsupported Media Type if the request is malformed") {
     val clock                = java.time.Clock.systemUTC
     val authenticationConfig = configuration.service.authentication
+    val externalConfig       = configuration.service.external
     val repo                 = new TestAuthenticationRepository[IO](List.empty, List.empty)
     val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
 
     def service: HttpRoutes[IO] =
-      Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+      Router(
+        "/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, externalConfig, repo, signAndValidate).routes
+      )
 
     val payload = "This is not the request body you are looking for.".getBytes(StandardCharsets.UTF_8)
     def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
@@ -106,11 +112,14 @@
 
     val clock                = java.time.Clock.systemUTC
     val authenticationConfig = configuration.service.authentication
+    val externalConfig       = configuration.service.external
     val repo                 = new TestAuthenticationRepository[IO](List.empty, List.empty)
     val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
 
     def service: HttpRoutes[IO] =
-      Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+      Router(
+        "/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, externalConfig, repo, signAndValidate).routes
+      )
 
     val payload = UrlForm.empty
     def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload)
@@ -135,11 +144,20 @@
       case Some(account) =>
         val clock                = java.time.Clock.systemUTC
         val authenticationConfig = configuration.service.authentication
+        val externalConfig       = configuration.service.external
         val repo                 = new TestAuthenticationRepository[IO](List(account), List.empty)
         val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
 
         def service: HttpRoutes[IO] =
-          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+          Router(
+            "/" -> new AuthenticationRoutes[IO](
+              clock,
+              authenticationConfig,
+              externalConfig,
+              repo,
+              signAndValidate
+            ).routes
+          )
 
         val payload = UrlForm.empty
           .updateFormField(LoginForm.fieldName.toString, account.name.toString)
@@ -177,13 +195,22 @@
       case Some(account) =>
         val clock                = java.time.Clock.systemUTC
         val authenticationConfig = configuration.service.authentication
+        val externalConfig       = configuration.service.external
         val repo                 = new TestAuthenticationRepository[IO](List(account), List.empty)
         val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
 
         for (_ <- 0 to authenticationConfig.lockAfter.toInt) yield repo.incrementFailedAttempts(account.uid)
 
         def service: HttpRoutes[IO] =
-          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+          Router(
+            "/" -> new AuthenticationRoutes[IO](
+              clock,
+              authenticationConfig,
+              externalConfig,
+              repo,
+              signAndValidate
+            ).routes
+          )
 
         val payload = UrlForm.empty
           .updateFormField(LoginForm.fieldName.toString, account.name.toString)
@@ -219,12 +246,21 @@
       case Some(account) =>
         val clock                = java.time.Clock.systemUTC
         val authenticationConfig = configuration.service.authentication
+        val externalConfig       = configuration.service.external
         // A locked account is not supposed to be returned by findAccount, so we leave the account list empty.
         val repo            = new TestAuthenticationRepository[IO](List.empty, List.empty)
         val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret)
 
         def service: HttpRoutes[IO] =
-          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+          Router(
+            "/" -> new AuthenticationRoutes[IO](
+              clock,
+              authenticationConfig,
+              externalConfig,
+              repo,
+              signAndValidate
+            ).routes
+          )
 
         val payload = UrlForm.empty
           .updateFormField(LoginForm.fieldName.toString, account.name.toString)
@@ -258,11 +294,20 @@
       case Some(account) =>
         val clock                = java.time.Clock.systemUTC
         val authenticationConfig = configuration.service.authentication
+        val externalConfig       = configuration.service.external
         val repo                 = new TestAuthenticationRepository[IO](List(account), List.empty)
         val signAndValidate      = SignAndValidate(configuration.service.authentication.cookieSecret)
 
         def service: HttpRoutes[IO] =
-          Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes)
+          Router(
+            "/" -> new AuthenticationRoutes[IO](
+              clock,
+              authenticationConfig,
+              externalConfig,
+              repo,
+              signAndValidate
+            ).routes
+          )
 
         val payload = UrlForm.empty
           .updateFormField(LoginForm.fieldName.toString, account.name.toString)
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/SessionHelpersTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/SessionHelpersTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/SessionHelpersTest.scala	2025-01-16 09:54:51.101214182 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/SessionHelpersTest.scala	2025-01-16 09:54:51.101214182 +0000
@@ -50,10 +50,12 @@
           signAndValidate: SignAndValidate,
           token: String
       ) =>
+        val domain      = Option("example.com")
         val signedToken = signAndValidate.signToken(token)(nonce)
-        val obtained    = signedToken.toAuthenticationCookie(expires)(secure)
+        val obtained    = signedToken.toAuthenticationCookie(domain)(expires)(secure)
         assertEquals(obtained.name, Constants.authenticationCookieName.toString)
         assertEquals(obtained.content, signedToken.toString)
+        assertEquals(obtained.domain, domain)
         assertEquals(obtained.sameSite, Option(SameSite.Strict))
         assertEquals(obtained.secure, secure)
         assertEquals(obtained.httpOnly, true)