Improve logging and rename oauth->openid

This commit is contained in:
eikek 2021-09-05 23:43:25 +02:00
parent 984dda9da0
commit 8158e36d40
6 changed files with 82 additions and 55 deletions

View File

@ -45,13 +45,13 @@ object CodeFlow {
for { for {
_ <- OptionT.liftF( _ <- OptionT.liftF(
logger.debug( logger.trace(
s"Obtaining access_token for provider ${cfg.providerId.id} and code $code" s"Obtaining access_token for provider ${cfg.providerId.id} and code $code"
) )
) )
token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code) token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code)
_ <- OptionT.liftF( _ <- OptionT.liftF(
logger.debug( logger.trace(
s"Obtaining user-info for provider ${cfg.providerId.id} and token $token" s"Obtaining user-info for provider ${cfg.providerId.id} and token $token"
) )
) )
@ -70,7 +70,7 @@ object CodeFlow {
case _ => case _ =>
OptionT OptionT
.liftF( .liftF(
logger.error( logger.warn(
s"No signature specified and no user endpoint url. Cannot obtain user info from access token!" 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 <- r.attemptAs[AccessToken].value
_ <- token match { _ <- token match {
case Right(t) => case Right(t) =>
logger.debug(s"Got token response: $t") logger.trace(s"Got token response: $t")
case Left(err) => case Left(err) =>
logger.error(err)(s"Error decoding access token: ${err.getMessage}") logger.error(err)(s"Error decoding access token: ${err.getMessage}")
} }

View File

@ -50,18 +50,18 @@ object CodeFlowRoutes {
) )
.withQuery("response_type", "code") .withQuery("response_type", "code")
logger.debug( logger.debug(
s"Redirecting to OAuth provider ${cfg.providerId.id}: ${uri.asString}" s"Redirecting to OAuth/OIDC provider ${cfg.providerId.id}: ${uri.asString}"
) ) *>
SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString))))
case None => case None =>
logger.debug(s"No oauth provider found with id '$id'") *> logger.debug(s"No OAuth/OIDC provider found with id '$id'") *>
NotFound() NotFound()
} }
case req @ GET -> Root / Ident(id) / "resume" => case req @ GET -> Root / Ident(id) / "resume" =>
config.findProvider(id) match { config.findProvider(id) match {
case None => case None =>
logger.debug(s"No oauth provider found with id '$id'") *> logger.debug(s"No OAuth/OIDC provider found with id '$id'") *>
NotFound() NotFound()
case Some(provider) => case Some(provider) =>
val codeFromReq = OptionT.fromOption[F](req.params.get("code")) 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}")) _ <- OptionT.liftF(logger.info(s"Resume OAuth/OIDC flow for ${id.id}"))
code <- codeFromReq code <- codeFromReq
_ <- OptionT.liftF( _ <- OptionT.liftF(
logger.debug( logger.trace(
s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code" s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code"
) )
) )
@ -92,7 +92,7 @@ object CodeFlowRoutes {
.map(err => s": $err") .map(err => s": $err")
.getOrElse("") .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) onUserInfo.handle(req, provider, None)
} }
} }

View File

@ -61,53 +61,67 @@ docspell.server {
} }
} }
# Configures OpenID Connect or OAuth2 authentication. Only the # Configures OpenID Connect (OIDC) or OAuth2 authentication. Only
# "Authorization Code Flow" is supported. # 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 # configured in the array below. The `provider` block gives all
# details necessary to authenticate agains an external OpenIdConnect # details necessary to authenticate agains an external OIDC or OAuth
# or OAuth provider. This requires at least two URLs for # provider. This requires at least two URLs for OIDC and three for
# OpenIdConnect and three for OAuth2. The `user-url` is only # OAuth2. The `user-url` is only required for OIDC, if the account
# required for OpenIdConnect, if the account data is to be retrieved # data is to be retrieved from the user-info endpoint and not from
# from the user-info endpoint and not from the access token. This # the JWT token. The access token is then used to authenticate at
# will use the access token to authenticate at the provider to # the provider to obtain user info. Thus, it doesn't need to be
# obtain user info. Thus, it doesn't need to be validated and # validated here and therefore no `sign-key` setting is needed.
# therefore no `sign-key` setting is needed. However, if you want to # However, if you want to extract the account information from the
# extract the account information from the access token, it must be # access token, it must be validated here and therefore the correct
# validated here and therefore the correct signature key and # signature key and algorithm must be provided. This would save
# algorithm must be provided. # 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 # After successful authentication, docspell needs to create the
# account. For this a username and collective name is required. # account. For this a username and collective name is required. The
# There are the following ways to specify how to retrieve this info # username is defined by the `user-key` setting. The `user-key` is
# depending on the value of `collective-key`. The `user-key` is used # used to search the JSON structure, that is obtained from the JWT
# to search the JSON structure, that is obtained from the JWT token # token or the user-info endpoint, for the login name to use. It
# or the user-info endpoint, for the login name to use. It traverses # traverses the JSON structure recursively, until it finds an object
# the JSON structure recursively, until it finds an object with that # with that key. The first value is used.
# key. The first value is used.
# #
# If it starts with `fixed:`, like "fixed:collective", the name # There are the following ways to specify how to retrieve the full
# after the `fixed:` prefix is used as collective as is. So all # account id depending on the value of `collective-key`:
# users are in the same collective.
# #
# If it starts with `lookup:`, like "lookup:collective_name", the # - If it starts with `fixed:`, like "fixed:collective", the name
# value after the prefix is used to search the JSON response for an # after the `fixed:` prefix is used as collective as is. So all
# object with this key, just like it works with the `user-key`. # users are in the same collective.
# #
# If it starts with `account:`, like "account:doscpell-collective", # - If it starts with `lookup:`, like "lookup:collective_name", the
# it works the same as `lookup:` only that it is interpreted as the # value after the prefix is used to search the JSON response for
# account name of form `collective/name`. The `user-key` value is # an object with this key, just like it works with the `user-key`.
# ignored in this case. #
# - 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 # Below are examples for OpenID Connect (keycloak) and OAuth2
# (github). # (github).
openid = openid =
[ { enabled = false, [ { enabled = false,
# The name to render on the login link/button.
display = "Keycloak"
# This illustrates to use a custom keycloak setup as the # This illustrates to use a custom keycloak setup as the
# authentication provider. For details, please refer to its # authentication provider. For details, please refer to the
# documentation. # keycloak documentation. The settings here assume a certain
# configuration at keycloak.
# #
# Keycloak can be configured to return the collective name for # Keycloak can be configured to return the collective name for
# each user in the access token. It may also be configured to # each user in the access token. It may also be configured to
@ -120,7 +134,7 @@ docspell.server {
provider-id = "keycloak", provider-id = "keycloak",
client-id = "docspell", client-id = "docspell",
client-secret = "example-secret-439e-bf06-911e4cdd56a6", 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", authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token", token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
#User URL is not used when signature key is set. #User URL is not used when signature key is set.
@ -136,22 +150,27 @@ docspell.server {
}, },
{ enabled = false, { enabled = false,
# The name to render on the login link/button.
display = "Github"
# Provider settings for using github as an authentication # Provider settings for using github as an authentication
# provider. Note that this is only an example to illustrate # provider. Note that this is only an example to illustrate
# how it works. Usually you wouldn't want to let every user on # how it works. Usually you wouldn't want to let every user on
# github in ;-). # github in ;-).
# #
# Github doesn't have full OpenIdConnect yet, but supports the # Github doesn't have full OpenIdConnect, but supports the
# OAuth2 code flow. # 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 = {
provider-id = "github", provider-id = "github",
client-id = "<your github client id>", client-id = "<your github client id>",
client-secret = "<your github client secret>", client-secret = "<your github client secret>",
scope = "", scope = "", # scope is not needed for github
authorize-url = "https://github.com/login/oauth/authorize", authorize-url = "https://github.com/login/oauth/authorize",
token-url = "https://github.com/login/oauth/access_token", token-url = "https://github.com/login/oauth/access_token",
user-url = "https://api.github.com/user", 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 sig-algo = "RS256" #unused but must be set to something
}, },

View File

@ -78,7 +78,8 @@ object Config {
object FullTextSearch {} object FullTextSearch {}
final case class OpenIdConfig( final case class OpenIdConfig(
enabled: Boolean, enabled: Boolean,
display: String,
collectiveKey: OpenId.UserInfo.Extractor, collectiveKey: OpenId.UserInfo.Extractor,
userKey: String, userKey: String,
provider: ProviderConfig provider: ProviderConfig

View File

@ -109,7 +109,7 @@ object RestServer {
restApp: RestApp[F] restApp: RestApp[F]
): HttpRoutes[F] = ): HttpRoutes[F] =
Router( Router(
"auth/oauth" -> CodeFlowRoutes( "auth/openid" -> CodeFlowRoutes(
cfg.openIdEnabled, cfg.openIdEnabled,
OpenId.handle[F](restApp.backend, cfg), OpenId.handle[F](restApp.backend, cfg),
OpenId.codeFlowConfig(cfg), OpenId.codeFlowConfig(cfg),

View File

@ -31,7 +31,7 @@ object OpenId {
CodeFlowConfig( CodeFlowConfig(
req => req =>
ClientRequestInfo ClientRequestInfo
.getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "oauth", .getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "openid",
id => id =>
config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider) config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider)
) )
@ -42,7 +42,7 @@ object OpenId {
import dsl._ import dsl._
val logger = Logger.log4s(log) val logger = Logger.log4s(log)
val baseUrl = ClientRequestInfo.getBaseUrl(config, req) 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 location = Location(Uri.unsafeFromString(uri.asString))
val cfg = config.openid val cfg = config.openid
.find(_.provider.providerId == provider.providerId) .find(_.provider.providerId == provider.providerId)
@ -54,7 +54,7 @@ object OpenId {
extractColl match { extractColl match {
case ExtractResult.Failure(message) => 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) TemporaryRedirect(location)
case ExtractResult.Account(accountId) => case ExtractResult.Account(accountId) =>
@ -63,7 +63,7 @@ object OpenId {
case ExtractResult.Identifier(coll) => case ExtractResult.Identifier(coll) =>
Extractor.Lookup(cfg.userKey).find(userJson) match { Extractor.Lookup(cfg.userKey).find(userJson) match {
case ExtractResult.Failure(message) => 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) TemporaryRedirect(location)
case ExtractResult.Identifier(name) => case ExtractResult.Identifier(name) =>
@ -158,6 +158,7 @@ object OpenId {
sealed trait Extractor { sealed trait Extractor {
def find(json: Json): ExtractResult def find(json: Json): ExtractResult
def asString: String
} }
object Extractor { object Extractor {
final case class Fixed(value: String) extends Extractor { final case class Fixed(value: String) extends Extractor {
@ -165,6 +166,8 @@ object OpenId {
UserInfoDecoder UserInfoDecoder
.normalizeUid(value) .normalizeUid(value)
.fold(err => ExtractResult.Failure(err), ExtractResult.Identifier) .fold(err => ExtractResult.Failure(err), ExtractResult.Identifier)
val asString = s"fixed:$value"
} }
final case class Lookup(value: String) extends Extractor { final case class Lookup(value: String) extends Extractor {
@ -176,6 +179,8 @@ object OpenId {
err => ExtractResult.Failure(err.getMessage()), err => ExtractResult.Failure(err.getMessage()),
ExtractResult.Identifier ExtractResult.Identifier
) )
val asString = s"lookup:$value"
} }
final case class AccountLookup(value: String) extends Extractor { final case class AccountLookup(value: String) extends Extractor {
@ -185,6 +190,8 @@ object OpenId {
.emap(AccountId.parse) .emap(AccountId.parse)
.decodeJson(json) .decodeJson(json)
.fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account) .fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account)
def asString = s"account:$value"
} }
def fromString(str: String): Either[String, Extractor] = def fromString(str: String): Either[String, Extractor] =