diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala index 6865ca39..a949eb42 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala @@ -45,13 +45,13 @@ object CodeFlow { for { _ <- OptionT.liftF( - logger.debug( + logger.trace( s"Obtaining access_token for provider ${cfg.providerId.id} and code $code" ) ) token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code) _ <- OptionT.liftF( - logger.debug( + logger.trace( s"Obtaining user-info for provider ${cfg.providerId.id} and token $token" ) ) @@ -70,7 +70,7 @@ object CodeFlow { case _ => OptionT .liftF( - logger.error( + logger.warn( s"No signature specified and no user endpoint url. Cannot obtain user info from access token!" ) ) @@ -113,7 +113,7 @@ object CodeFlow { token <- r.attemptAs[AccessToken].value _ <- token match { case Right(t) => - logger.debug(s"Got token response: $t") + logger.trace(s"Got token response: $t") case Left(err) => logger.error(err)(s"Error decoding access token: ${err.getMessage}") } diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala index e9a43532..ca0e51b5 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala @@ -50,18 +50,18 @@ object CodeFlowRoutes { ) .withQuery("response_type", "code") logger.debug( - s"Redirecting to OAuth provider ${cfg.providerId.id}: ${uri.asString}" - ) - SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) + s"Redirecting to OAuth/OIDC provider ${cfg.providerId.id}: ${uri.asString}" + ) *> + SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) case None => - logger.debug(s"No oauth provider found with id '$id'") *> + logger.debug(s"No OAuth/OIDC provider found with id '$id'") *> NotFound() } case req @ GET -> Root / Ident(id) / "resume" => config.findProvider(id) match { case None => - logger.debug(s"No oauth provider found with id '$id'") *> + logger.debug(s"No OAuth/OIDC provider found with id '$id'") *> NotFound() case Some(provider) => val codeFromReq = OptionT.fromOption[F](req.params.get("code")) @@ -70,7 +70,7 @@ object CodeFlowRoutes { _ <- OptionT.liftF(logger.info(s"Resume OAuth/OIDC flow for ${id.id}")) code <- codeFromReq _ <- OptionT.liftF( - logger.debug( + logger.trace( s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code" ) ) @@ -92,7 +92,7 @@ object CodeFlowRoutes { .map(err => s": $err") .getOrElse("") - logger.error(s"Error resuming code flow from '${id.id}'$reason") *> + logger.warn(s"Error resuming code flow from '${id.id}'$reason") *> onUserInfo.handle(req, provider, None) } } diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 26bd0f7f..be13766a 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -61,53 +61,67 @@ docspell.server { } } - # Configures OpenID Connect or OAuth2 authentication. Only the - # "Authorization Code Flow" is supported. + # Configures OpenID Connect (OIDC) or OAuth2 authentication. Only + # the "Authorization Code Flow" is supported. # - # Multiple authentication providers are supported. Each is + # Multiple authentication providers can be defined. 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. + # details necessary to authenticate agains an external OIDC or OAuth + # provider. This requires at least two URLs for OIDC and three for + # OAuth2. The `user-url` is only required for OIDC, if the account + # data is to be retrieved from the user-info endpoint and not from + # the JWT token. The access token is then used to authenticate at + # the provider to obtain user info. Thus, it doesn't need to be + # validated here 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. This would save + # another request. If the `sign-key` is left empty, the `user-url` + # is used and must be specified. If the `sign-key` is _not_ empty, + # the response from the authentication provider is validated using + # this key. # # 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. + # account. For this a username and collective name is required. The + # username is defined by the `user-key` setting. 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. + # There are the following ways to specify how to retrieve the full + # account id depending on the value of `collective-key`: # - # 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 `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 `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. + # - 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:ds-account", it + # works the same as `lookup:` only that the value is interpreted + # as the full account name of form `collective/login`. The + # `user-key` value is ignored in this case. + # + # If these values cannot be obtained from the response, docspell + # fails the authentication by denying access. It is then assumed + # that the successfully authenticated user has not enough + # permissions to access docspell. # # Below are examples for OpenID Connect (keycloak) and OAuth2 # (github). openid = [ { enabled = false, + # The name to render on the login link/button. + display = "Keycloak" + # This illustrates to use a custom keycloak setup as the - # authentication provider. For details, please refer to its - # documentation. + # authentication provider. For details, please refer to the + # keycloak documentation. The settings here assume a certain + # configuration at keycloak. # # Keycloak can be configured to return the collective name for # each user in the access token. It may also be configured to @@ -120,7 +134,7 @@ docspell.server { provider-id = "keycloak", client-id = "docspell", client-secret = "example-secret-439e-bf06-911e4cdd56a6", - scope = "docspell", + scope = "docspell", # scope is required for OIDC authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth", token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token", #User URL is not used when signature key is set. @@ -136,22 +150,27 @@ docspell.server { }, { enabled = false, + # The name to render on the login link/button. + display = "Github" + # 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. + # Github doesn't have full OpenIdConnect, but supports the + # OAuth2 code flow (which is very similar). It mainly means, + # that there is no standardized token to validate and get + # information from. So the user-url must be used in this case. provider = { provider-id = "github", client-id = "", client-secret = "", - scope = "", + scope = "", # scope is not needed for github authorize-url = "https://github.com/login/oauth/authorize", token-url = "https://github.com/login/oauth/access_token", user-url = "https://api.github.com/user", - sign-key = "" + sign-key = "" # this must be set empty sig-algo = "RS256" #unused but must be set to something }, diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index 6d8d291d..c0f7afe2 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -78,7 +78,8 @@ object Config { object FullTextSearch {} final case class OpenIdConfig( - enabled: Boolean, + enabled: Boolean, + display: String, collectiveKey: OpenId.UserInfo.Extractor, userKey: String, provider: ProviderConfig diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 0d867304..38e43c9c 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -109,7 +109,7 @@ object RestServer { restApp: RestApp[F] ): HttpRoutes[F] = Router( - "auth/oauth" -> CodeFlowRoutes( + "auth/openid" -> CodeFlowRoutes( cfg.openIdEnabled, OpenId.handle[F](restApp.backend, cfg), OpenId.codeFlowConfig(cfg), 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 38111c82..ae762755 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala @@ -31,7 +31,7 @@ object OpenId { CodeFlowConfig( req => ClientRequestInfo - .getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "oauth", + .getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "openid", id => config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider) ) @@ -42,7 +42,7 @@ object OpenId { import dsl._ val logger = Logger.log4s(log) val baseUrl = ClientRequestInfo.getBaseUrl(config, req) - val uri = baseUrl.withQuery("oauth", "1") / "app" / "login" + val uri = baseUrl.withQuery("openid", "1") / "app" / "login" val location = Location(Uri.unsafeFromString(uri.asString)) val cfg = config.openid .find(_.provider.providerId == provider.providerId) @@ -54,7 +54,7 @@ object OpenId { extractColl match { case ExtractResult.Failure(message) => - logger.error(s"Error retrieving user data: $message") *> + logger.warn(s"Can't retrieve user data using collective-key=${cfg.collectiveKey.asString}: $message") *> TemporaryRedirect(location) case ExtractResult.Account(accountId) => @@ -63,7 +63,7 @@ object OpenId { case ExtractResult.Identifier(coll) => Extractor.Lookup(cfg.userKey).find(userJson) match { case ExtractResult.Failure(message) => - logger.error(s"Error retrieving user data: $message") *> + logger.warn(s"Can't retrieve user data using user-key=${cfg.userKey}: $message") *> TemporaryRedirect(location) case ExtractResult.Identifier(name) => @@ -158,6 +158,7 @@ object OpenId { sealed trait Extractor { def find(json: Json): ExtractResult + def asString: String } object Extractor { final case class Fixed(value: String) extends Extractor { @@ -165,6 +166,8 @@ object OpenId { UserInfoDecoder .normalizeUid(value) .fold(err => ExtractResult.Failure(err), ExtractResult.Identifier) + + val asString = s"fixed:$value" } final case class Lookup(value: String) extends Extractor { @@ -176,6 +179,8 @@ object OpenId { err => ExtractResult.Failure(err.getMessage()), ExtractResult.Identifier ) + + val asString = s"lookup:$value" } final case class AccountLookup(value: String) extends Extractor { @@ -185,6 +190,8 @@ object OpenId { .emap(AccountId.parse) .decodeJson(json) .fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account) + + def asString = s"account:$value" } def fromString(str: String): Either[String, Extractor] =