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 = "", @@ -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 + } + } }