From f8362329a9666c989daf38a35db24dfb2106b98f Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sun, 5 Sep 2021 21:39:09 +0200
Subject: [PATCH] Authenticate with external accounts using OIDC

After successful authentication at the provider, an account is
automatically created at docspell and the user is logged in.
---
 .../scala/docspell/backend/auth/Login.scala   |  45 ++--
 .../docspell/backend/ops/OCollective.scala    |   2 +
 .../backend/signup/ExternalAccount.scala      |  12 +
 .../docspell/backend/signup/OSignup.scala     |  42 ++--
 .../backend/signup/RegisterData.scala         |   6 +
 .../scala/docspell/common/AccountSource.scala |   7 +
 .../scala/docspell/oidc/CodeFlowRoutes.scala  |  15 +-
 .../scala/docspell/oidc/UserInfoDecoder.scala |  13 +-
 .../src/main/resources/reference.conf         |  78 ++++++-
 .../scala/docspell/restserver/Config.scala    |  13 +-
 .../docspell/restserver/ConfigFile.scala      |   4 +
 .../docspell/restserver/RestServer.scala      |   1 +
 .../docspell/restserver/auth/OpenId.scala     | 219 +++++++++++++++---
 13 files changed, 382 insertions(+), 75 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
index bfca90e4..501c1040 100644
--- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
+++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
@@ -23,6 +23,8 @@ import scodec.bits.ByteVector
 
 trait Login[F[_]] {
 
+  def loginExternal(config: Config)(accountId: AccountId): F[Result]
+
   def loginSession(config: Config)(sessionKey: String): F[Result]
 
   def loginUserPass(config: Config)(up: UserPass): F[Result]
@@ -93,6 +95,16 @@ object Login {
 
       private val logF = Logger.log4s(logger)
 
+      def loginExternal(config: Config)(accountId: AccountId): F[Result] =
+        for {
+          data <- store.transact(QLogin.findUser(accountId))
+          _    <- logF.trace(s"Account lookup: $data")
+          res <-
+            if (data.exists(checkNoPassword(_, Set(AccountSource.OpenId))))
+              doLogin(config, accountId, false)
+            else Result.invalidAuth.pure[F]
+        } yield res
+
       def loginSession(config: Config)(sessionKey: String): F[Result] =
         AuthToken.fromString(sessionKey) match {
           case Right(at) =>
@@ -110,24 +122,11 @@ object Login {
       def loginUserPass(config: Config)(up: UserPass): F[Result] =
         AccountId.parse(up.user) match {
           case Right(acc) =>
-            val okResult =
-              for {
-                require2FA <- store.transact(RTotp.isEnabled(acc))
-                _ <-
-                  if (require2FA) ().pure[F]
-                  else store.transact(RUser.updateLogin(acc))
-                token <- AuthToken.user(acc, require2FA, config.serverSecret)
-                rem <- OptionT
-                  .whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)(
-                    insertRememberToken(store, acc, config)
-                  )
-                  .value
-              } yield Result.ok(token, rem)
             for {
               data <- store.transact(QLogin.findUser(acc))
               _    <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
               res <-
-                if (data.exists(check(up.pass))) okResult
+                if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe)
                 else Result.invalidAuth.pure[F]
             } yield res
           case Left(_) =>
@@ -247,6 +246,24 @@ object Login {
             0.pure[F]
         }
 
+      private def doLogin(
+          config: Config,
+          acc: AccountId,
+          rememberMe: Boolean
+      ): F[Result] =
+        for {
+          require2FA <- store.transact(RTotp.isEnabled(acc))
+          _ <-
+            if (require2FA) ().pure[F]
+            else store.transact(RUser.updateLogin(acc))
+          token <- AuthToken.user(acc, require2FA, config.serverSecret)
+          rem <- OptionT
+            .whenF(!require2FA && rememberMe && config.rememberMe.enabled)(
+              insertRememberToken(store, acc, config)
+            )
+            .value
+        } yield Result.ok(token, rem)
+
       private def insertRememberToken(
           store: Store[F],
           acc: AccountId,
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala
index 0efc7225..3e93217b 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala
@@ -9,6 +9,7 @@ package docspell.backend.ops
 import cats.effect.{Async, Resource}
 import cats.implicits._
 import fs2.Stream
+
 import docspell.backend.JobFactory
 import docspell.backend.PasswordCrypt
 import docspell.backend.ops.OCollective._
@@ -19,6 +20,7 @@ import docspell.store.queue.JobQueue
 import docspell.store.records._
 import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore}
 import docspell.store.{AddResult, Store}
+
 import com.github.eikek.calev._
 
 trait OCollective[F[_]] {
diff --git a/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala b/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala
index 2923a57f..e6ddc01a 100644
--- a/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala
+++ b/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala
@@ -1,3 +1,9 @@
+/*
+ * Copyright 2020 Docspell Contributors
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
 package docspell.backend.signup
 
 import docspell.common._
@@ -11,3 +17,9 @@ final case class ExternalAccount(
   def toAccountId: AccountId =
     AccountId(collName, login)
 }
+
+object ExternalAccount {
+  def apply(accountId: AccountId): ExternalAccount =
+    ExternalAccount(accountId.collective, accountId.user, AccountSource.OpenId)
+
+}
diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala
index 15a9de16..381be35e 100644
--- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala
+++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala
@@ -8,11 +8,13 @@ package docspell.backend.signup
 
 import cats.effect.{Async, Resource}
 import cats.implicits._
+
 import docspell.backend.PasswordCrypt
 import docspell.common._
 import docspell.common.syntax.all._
 import docspell.store.records.{RCollective, RInvitation, RUser}
 import docspell.store.{AddResult, Store}
+
 import doobie.free.connection.ConnectionIO
 import org.log4s.getLogger
 
@@ -83,23 +85,29 @@ object OSignup {
             SignupResult.signupClosed.pure[F]
           case _ =>
             if (data.source == AccountSource.Local)
-              SignupResult.failure(new Exception("Account source must not be LOCAL!")).pure[F]
-            else for {
-              recs <- makeRecords(data.collName, data.login, Password(""), data.source)
-              cres <- store.add(RCollective.insert(recs._1), RCollective.existsById(data.collName))
-              ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login))
-              res = cres match {
-                case AddResult.Failure(ex) =>
-                  SignupResult.failure(ex)
-                case _ =>
-                  ures match {
-                    case AddResult.Failure(ex) =>
-                      SignupResult.failure(ex)
-                    case _ =>
-                      SignupResult.success
-                  }
-              }
-            } yield res
+              SignupResult
+                .failure(new Exception("Account source must not be LOCAL!"))
+                .pure[F]
+            else
+              for {
+                recs <- makeRecords(data.collName, data.login, Password(""), data.source)
+                cres <- store.add(
+                  RCollective.insert(recs._1),
+                  RCollective.existsById(data.collName)
+                )
+                ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login))
+                res = cres match {
+                  case AddResult.Failure(ex) =>
+                    SignupResult.failure(ex)
+                  case _ =>
+                    ures match {
+                      case AddResult.Failure(ex) =>
+                        SignupResult.failure(ex)
+                      case _ =>
+                        SignupResult.success
+                    }
+                }
+              } yield res
         }
 
       private def retryInvite(res: SignupResult): Boolean =
diff --git a/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala b/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala
index 3ce905be..94bb3aeb 100644
--- a/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala
+++ b/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala
@@ -1,3 +1,9 @@
+/*
+ * Copyright 2020 Docspell Contributors
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
 package docspell.backend.signup
 import docspell.common._
 
diff --git a/modules/common/src/main/scala/docspell/common/AccountSource.scala b/modules/common/src/main/scala/docspell/common/AccountSource.scala
index f2efecb7..71beef0b 100644
--- a/modules/common/src/main/scala/docspell/common/AccountSource.scala
+++ b/modules/common/src/main/scala/docspell/common/AccountSource.scala
@@ -1,6 +1,13 @@
+/*
+ * Copyright 2020 Docspell Contributors
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
 package docspell.common
 
 import cats.data.NonEmptyList
+
 import io.circe.{Decoder, Encoder}
 
 sealed trait AccountSource { self: Product =>
diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
index 0f3cfda5..e9a43532 100644
--- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
+++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
@@ -6,12 +6,10 @@
 
 package docspell.oidc
 
-import cats.data.OptionT
+import cats.data.{Kleisli, OptionT}
 import cats.effect._
 import cats.implicits._
-
 import docspell.common._
-
 import org.http4s.HttpRoutes
 import org.http4s._
 import org.http4s.client.Client
@@ -22,7 +20,16 @@ import org.log4s.getLogger
 object CodeFlowRoutes {
   private[this] val log4sLogger = getLogger
 
-  def apply[F[_]: Async, A](
+  def apply[F[_]: Async](
+      enabled: Boolean,
+      onUserInfo: OnUserInfo[F],
+      config: CodeFlowConfig[F],
+      client: Client[F]
+  ): HttpRoutes[F] =
+    if (enabled) route[F](onUserInfo, config, client)
+    else Kleisli(_ => OptionT.pure(Response.notFound[F]))
+
+  def route[F[_]: Async](
       onUserInfo: OnUserInfo[F],
       config: CodeFlowConfig[F],
       client: Client[F]
diff --git a/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala b/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala
index f9bce25a..f6837788 100644
--- a/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala
+++ b/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala
@@ -20,21 +20,26 @@ object UserInfoDecoder {
     findSomeId("preferred_username")
 
   /** Looks recursively in the JSON for the first attribute with name `key` and returns
-    * its value (expecting an Ident).
+    * its value.
     */
-  def findSomeId(key: String): Decoder[Ident] =
+  def findSomeString(key: String): Decoder[String] =
     Decoder.instance { cursor =>
       cursor.value
         .findAllByKey(key)
         .find(_.isString)
         .flatMap(_.asString)
         .toRight(s"No value found in JSON for key '$key'")
-        .flatMap(normalizeUid)
         .left
         .map(msg => DecodingFailure(msg, Nil))
     }
 
-  private def normalizeUid(uid: String): Either[String, Ident] =
+  /** Looks recursively in the JSON for the first attribute with name `key` and returns
+    * its value (expecting an Ident).
+    */
+  def findSomeId(key: String): Decoder[Ident] =
+    findSomeString(key).emap(normalizeUid)
+
+  def normalizeUid(uid: String): Either[String, Ident] =
     Ident(uid.filter(Ident.chars.contains))
       .flatMap(id =>
         if (id.nonEmpty) Right(id) else Left(s"Id '$uid' empty after normalizing!'")
diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf
index 8020ee1b..26bd0f7f 100644
--- a/modules/restserver/src/main/resources/reference.conf
+++ b/modules/restserver/src/main/resources/reference.conf
@@ -64,17 +64,62 @@ docspell.server {
   # Configures OpenID Connect or OAuth2 authentication. Only the
   # "Authorization Code Flow" is supported.
   #
-  # When using OpenID Connect, a scope is mandatory.
-  # TODO
+  # Multiple authentication providers are supported. Each is
+  # configured in the array below. The `provider` block gives all
+  # details necessary to authenticate agains an external OpenIdConnect
+  # or OAuth provider. This requires at least two URLs for
+  # OpenIdConnect and three for OAuth2. The `user-url` is only
+  # required for OpenIdConnect, if the account data is to be retrieved
+  # from the user-info endpoint and not from the access token. This
+  # will use the access token to authenticate at the provider to
+  # obtain user info. Thus, it doesn't need to be validated and
+  # therefore no `sign-key` setting is needed. However, if you want to
+  # extract the account information from the access token, it must be
+  # validated here and therefore the correct signature key and
+  # algorithm must be provided.
+  #
+  # After successful authentication, docspell needs to create the
+  # account. For this a username and collective name is required.
+  # There are the following ways to specify how to retrieve this info
+  # depending on the value of `collective-key`. The `user-key` is used
+  # to search the JSON structure, that is obtained from the JWT token
+  # or the user-info endpoint, for the login name to use. It traverses
+  # the JSON structure recursively, until it finds an object with that
+  # key. The first value is used.
+  #
+  # If it starts with `fixed:`, like "fixed:collective", the name
+  # after the `fixed:` prefix is used as collective as is. So all
+  # users are in the same collective.
+  #
+  # If it starts with `lookup:`, like "lookup:collective_name", the
+  # value after the prefix is used to search the JSON response for an
+  # object with this key, just like it works with the `user-key`.
+  #
+  # If it starts with `account:`, like "account:doscpell-collective",
+  # it works the same as `lookup:` only that it is interpreted as the
+  # account name of form `collective/name`. The `user-key` value is
+  # ignored in this case.
   #
   # Below are examples for OpenID Connect (keycloak) and OAuth2
   # (github).
   openid =
     [ { enabled = false,
+
+        # This illustrates to use a custom keycloak setup as the
+        # authentication provider. For details, please refer to its
+        # documentation.
+        #
+        # Keycloak can be configured to return the collective name for
+        # each user in the access token. It may also be configured to
+        # return it in the user info response. If it is already in the
+        # access token, an additional request can be omitted. Set the
+        # `sign-key` to an empty string then. Otherwise provide the
+        # algo and key from your realm settings. In this example, the
+        # realm is called "home".
         provider = {
           provider-id = "keycloak",
           client-id = "docspell",
-          client-secret = "21cd4550-6328-439e-bf06-911e4cdd56a6",
+          client-secret = "example-secret-439e-bf06-911e4cdd56a6",
           scope = "docspell",
           authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
           token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
@@ -82,9 +127,22 @@ docspell.server {
           #user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
           sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
           sig-algo = "RS512"
-        }
+        },
+        # The collective of the user is given in the access token as
+        # property `docspell_collective`.
+        collective-key = "lookup:docspell_collective",
+        # The username to use for the docspell account
+        user-key = "preferred_username"
       },
       { enabled = false,
+
+        # Provider settings for using github as an authentication
+        # provider. Note that this is only an example to illustrate
+        # how it works. Usually you wouldn't want to let every user on
+        # github in ;-).
+        #
+        # Github doesn't have full OpenIdConnect yet, but supports the
+        # OAuth2 code flow.
         provider = {
           provider-id = "github",
           client-id = "<your github client id>",
@@ -95,7 +153,17 @@ docspell.server {
           user-url = "https://api.github.com/user",
           sign-key = ""
           sig-algo = "RS256" #unused but must be set to something
-        }
+        },
+
+        # If the authentication provider doesn't provide the
+        # collective name, simply use a fixed one. This means all
+        # users from this provider are in the same collective!
+        collective-key = "fixed:demo",
+
+        # Github provides the login name via the `login` property as
+        # response from the user-url. This value is used to construct
+        # the account in docspell.
+        user-key = "login"
       }
     ]
 
diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala
index 8ab744c7..6d8d291d 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala
@@ -12,6 +12,7 @@ import docspell.common._
 import docspell.ftssolr.SolrConfig
 import docspell.oidc.ProviderConfig
 import docspell.restserver.Config.OpenIdConfig
+import docspell.restserver.auth.OpenId
 
 import com.comcast.ip4s.IpAddress
 
@@ -29,7 +30,10 @@ case class Config(
     fullTextSearch: Config.FullTextSearch,
     adminEndpoint: Config.AdminEndpoint,
     openid: List[OpenIdConfig]
-)
+) {
+  def openIdEnabled: Boolean =
+    openid.exists(_.enabled)
+}
 
 object Config {
 
@@ -73,6 +77,11 @@ object Config {
 
   object FullTextSearch {}
 
-  final case class OpenIdConfig(enabled: Boolean, provider: ProviderConfig)
+  final case class OpenIdConfig(
+      enabled: Boolean,
+      collectiveKey: OpenId.UserInfo.Extractor,
+      userKey: String,
+      provider: ProviderConfig
+  )
 
 }
diff --git a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala
index 4025bf50..8818e92a 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala
@@ -9,6 +9,7 @@ package docspell.restserver
 import docspell.backend.signup.{Config => SignupConfig}
 import docspell.common.config.Implicits._
 import docspell.oidc.SignatureAlgo
+import docspell.restserver.auth.OpenId
 
 import pureconfig._
 import pureconfig.generic.auto._
@@ -25,5 +26,8 @@ object ConfigFile {
 
     implicit val sigAlgoReader: ConfigReader[SignatureAlgo] =
       ConfigReader[String].emap(reason(SignatureAlgo.fromString))
+
+    implicit val openIdExtractorReader: ConfigReader[OpenId.UserInfo.Extractor] =
+      ConfigReader[String].emap(reason(OpenId.UserInfo.Extractor.fromString))
   }
 }
diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
index 0f4710eb..0d867304 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
@@ -110,6 +110,7 @@ object RestServer {
   ): HttpRoutes[F] =
     Router(
       "auth/oauth" -> CodeFlowRoutes(
+        cfg.openIdEnabled,
         OpenId.handle[F](restApp.backend, cfg),
         OpenId.codeFlowConfig(cfg),
         client
diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala
index e7c6dae2..38111c82 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala
@@ -7,12 +7,25 @@
 package docspell.restserver.auth
 
 import cats.effect._
+import cats.implicits._
+
 import docspell.backend.BackendApp
-import docspell.oidc.{CodeFlowConfig, OnUserInfo}
+import docspell.backend.auth.Login
+import docspell.backend.signup.{ExternalAccount, SignupResult}
+import docspell.common._
+import docspell.oidc.{CodeFlowConfig, OnUserInfo, UserInfoDecoder}
 import docspell.restserver.Config
+import docspell.restserver.auth.OpenId.UserInfo.{ExtractResult, Extractor}
 import docspell.restserver.http4s.ClientRequestInfo
 
+import io.circe.Json
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.Location
+import org.http4s.{Response, Uri}
+import org.log4s.getLogger
+
 object OpenId {
+  private[this] val log = getLogger
 
   def codeFlowConfig[F[_]](config: Config): CodeFlowConfig[F] =
     CodeFlowConfig(
@@ -23,35 +36,183 @@ object OpenId {
         config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider)
     )
 
-  def handle[F[_]: Async](backend: BackendApp[F], cfg: Config): OnUserInfo[F] =
-    OnUserInfo((req, provider, userInfo) =>
+  def handle[F[_]: Async](backend: BackendApp[F], config: Config): OnUserInfo[F] =
+    OnUserInfo { (req, provider, userInfo) =>
+      val dsl = new Http4sDsl[F] {}
+      import dsl._
+      val logger   = Logger.log4s(log)
+      val baseUrl  = ClientRequestInfo.getBaseUrl(config, req)
+      val uri      = baseUrl.withQuery("oauth", "1") / "app" / "login"
+      val location = Location(Uri.unsafeFromString(uri.asString))
+      val cfg = config.openid
+        .find(_.provider.providerId == provider.providerId)
+        .getOrElse(sys.error("No config found, but provider which is impossible :)"))
+
       userInfo match {
         case Some(userJson) =>
-          println(s"$backend $cfg")
-          OnUserInfo.logInfo[F].handle(req, provider, Some(userJson))
-        case None =>
-          OnUserInfo.logInfo[F].handle(req, provider, None)
-      }
-    )
+          val extractColl = cfg.collectiveKey.find(userJson)
 
-  //    u <- userInfo
-  //    newAcc <- OptionT.liftF(
-  //      NewAccount.create(u ++ Ident.unsafe(":") ++ p.providerId, AccountSource.OAuth(p.id.id))
-  //    )
-  //    acc <- OptionT.liftF(S.account.createIfMissing(newAcc))
-  //    accId = acc.accountId(None)
-  //    _ <- OptionT.liftF(S.account.updateLoginStats(accId))
-  //    token <- OptionT.liftF(
-  //      AuthToken.user[F](accId, cfg.backend.auth.serverSecret)
-  //    )
-  //  } yield token
-  //
-  //  val uri = getBaseUrl( req).withQuery("oauth", "1") / "app" / "login"
-  //  val location = Location(Uri.unsafeFromString(uri.asString))
-  //  userId.value.flatMap {
-  //    case Some(t) =>
-  //      TemporaryRedirect(location)
-  //        .map(_.addCookie(CookieData(t).asCookie(getBaseUrl(req))))
-  //    case None => TemporaryRedirect(location)
-  //  }
+          extractColl match {
+            case ExtractResult.Failure(message) =>
+              logger.error(s"Error retrieving user data: $message") *>
+                TemporaryRedirect(location)
+
+            case ExtractResult.Account(accountId) =>
+              signUpAndLogin[F](backend)(config, accountId, location, baseUrl)
+
+            case ExtractResult.Identifier(coll) =>
+              Extractor.Lookup(cfg.userKey).find(userJson) match {
+                case ExtractResult.Failure(message) =>
+                  logger.error(s"Error retrieving user data: $message") *>
+                    TemporaryRedirect(location)
+
+                case ExtractResult.Identifier(name) =>
+                  signUpAndLogin[F](backend)(
+                    config,
+                    AccountId(coll, name),
+                    location,
+                    baseUrl
+                  )
+
+                case ExtractResult.Account(accountId) =>
+                  signUpAndLogin[F](backend)(
+                    config,
+                    accountId.copy(collective = coll),
+                    location,
+                    baseUrl
+                  )
+              }
+          }
+
+        case None =>
+          TemporaryRedirect(location)
+      }
+    }
+
+  def signUpAndLogin[F[_]: Async](
+      backend: BackendApp[F]
+  )(
+      cfg: Config,
+      accountId: AccountId,
+      location: Location,
+      baseUrl: LenientUri
+  ): F[Response[F]] = {
+    val dsl = new Http4sDsl[F] {}
+    import dsl._
+
+    for {
+      setup <- backend.signup.setupExternal(cfg.backend.signup)(
+        ExternalAccount(accountId)
+      )
+      logger = Logger.log4s(log)
+      res <- setup match {
+        case SignupResult.Failure(ex) =>
+          logger.error(ex)(s"Error when creating external account!") *>
+            TemporaryRedirect(location)
+
+        case SignupResult.SignupClosed =>
+          logger.error(s"External accounts don't work when signup is closed!") *>
+            TemporaryRedirect(location)
+
+        case SignupResult.CollectiveExists =>
+          logger.error(
+            s"Error when creating external accounts! Collective exists error reported. This is a bug!"
+          ) *>
+            TemporaryRedirect(location)
+
+        case SignupResult.InvalidInvitationKey =>
+          logger.error(
+            s"Error when creating external accounts! Invalid invitation key reported. This is a bug!"
+          ) *>
+            TemporaryRedirect(location)
+
+        case SignupResult.Success =>
+          loginAndVerify(backend, cfg)(accountId, location, baseUrl)
+      }
+    } yield res
+  }
+
+  def loginAndVerify[F[_]: Async](backend: BackendApp[F], config: Config)(
+      accountId: AccountId,
+      location: Location,
+      baseUrl: LenientUri
+  ): F[Response[F]] = {
+    val dsl = new Http4sDsl[F] {}
+    import dsl._
+
+    for {
+      login <- backend.login.loginExternal(config.auth)(accountId)
+      resp <- login match {
+        case Login.Result.Ok(session, _) =>
+          TemporaryRedirect(location)
+            .map(_.addCookie(CookieData(session).asCookie(baseUrl)))
+
+        case failed =>
+          Logger.log4s(log).error(s"External login failed: $failed") *>
+            TemporaryRedirect(location)
+      }
+    } yield resp
+  }
+
+  object UserInfo {
+
+    sealed trait Extractor {
+      def find(json: Json): ExtractResult
+    }
+    object Extractor {
+      final case class Fixed(value: String) extends Extractor {
+        def find(json: Json): ExtractResult =
+          UserInfoDecoder
+            .normalizeUid(value)
+            .fold(err => ExtractResult.Failure(err), ExtractResult.Identifier)
+      }
+
+      final case class Lookup(value: String) extends Extractor {
+        def find(json: Json): ExtractResult =
+          UserInfoDecoder
+            .findSomeId(value)
+            .decodeJson(json)
+            .fold(
+              err => ExtractResult.Failure(err.getMessage()),
+              ExtractResult.Identifier
+            )
+      }
+
+      final case class AccountLookup(value: String) extends Extractor {
+        def find(json: Json): ExtractResult =
+          UserInfoDecoder
+            .findSomeString(value)
+            .emap(AccountId.parse)
+            .decodeJson(json)
+            .fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account)
+      }
+
+      def fromString(str: String): Either[String, Extractor] =
+        str.span(_ != ':') match {
+          case (_, "") =>
+            Left(s"Invalid extractor, there is no value: $str")
+          case (_, value) if value == ":" =>
+            Left(s"Invalid extractor, there is no value: $str")
+
+          case (prefix, value) =>
+            prefix.toLowerCase match {
+              case "fixed" =>
+                Right(Fixed(value.drop(1)))
+              case "lookup" =>
+                Right(Lookup(value.drop(1)))
+              case "account" =>
+                Right(AccountLookup(value.drop(1)))
+              case _ =>
+                Left(s"Invalid prefix: $prefix")
+            }
+        }
+    }
+
+    sealed trait ExtractResult
+    object ExtractResult {
+      final case class Identifier(name: Ident)       extends ExtractResult
+      final case class Account(accountId: AccountId) extends ExtractResult
+      final case class Failure(message: String)      extends ExtractResult
+    }
+  }
 }