Implement authentication via OpenIdConnect and OAuth2

The new subproject "oidc" handles all the details for working with an
OpenID Connect provider (like keycloak) or only OAuth2 - only
supporting the "Authorization Code Flow" for both variants.
This commit is contained in:
eikek 2021-09-05 16:29:42 +02:00
parent 48b35e175f
commit b73c252762
17 changed files with 902 additions and 5 deletions

View File

@ -502,6 +502,24 @@ val backend = project
)
.dependsOn(store, joexapi, ftsclient, totp)
val oidc = project
.in(file("modules/oidc"))
.disablePlugins(RevolverPlugin)
.settings(sharedSettings)
.settings(testSettingsMUnit)
.settings(
name := "docspell-oidc",
libraryDependencies ++=
Dependencies.loggingApi ++
Dependencies.fs2 ++
Dependencies.http4sClient ++
Dependencies.http4sCirce ++
Dependencies.http4sDsl ++
Dependencies.circe ++
Dependencies.jwtScala
)
.dependsOn(common)
val webapp = project
.in(file("modules/webapp"))
.disablePlugins(RevolverPlugin)
@ -615,7 +633,7 @@ val restserver = project
}
}
)
.dependsOn(restapi, joexapi, backend, webapp, ftssolr)
.dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc)
// --- Website Documentation
@ -695,7 +713,8 @@ val root = project
restserver,
query.jvm,
query.js,
totp
totp,
oidc
)
// --- Helpers

View File

@ -0,0 +1,63 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import io.circe.Decoder
import scodec.bits.ByteVector
/** The response from an authorization server to the "token request". The redirect request
* contains an authorization code that is used to request tokens at the authorization
* server. The server then responds with this structure.
*
* @param accessToken
* the jwt encoded access token
* @param tokenType
* the token type, is always 'Bearer'
* @param expiresIn
* when it expires (in seconds from unix epoch)
* @param refreshToken
* optional refresh token
* @param refreshExpiresIn
* optional expiry time for the refresh token (in seconds from unix epoch)
* @param sessionState
* an optional session state
* @param scope
* the scope as requested. this must be present for OpenId Connect, but not necessarily
* for OAuth2
*/
final case class AccessToken(
accessToken: String,
tokenType: String,
expiresIn: Option[Long],
refreshToken: Option[String],
refreshExpiresIn: Option[Long],
sessionState: Option[String],
scope: Option[String]
) {
/** Decodes the `accessToken` as a JWT and validates it given the key and expected
* signature algorithm.
*/
def decodeToken(key: ByteVector, algo: SignatureAlgo): Either[String, Jwt] =
SignatureAlgo.decoder(key, algo)(accessToken).left.map(_.getMessage)
}
object AccessToken {
implicit val decoder: Decoder[AccessToken] =
Decoder.instance { c =>
for {
atoken <- c.get[String]("access_token")
ttype <- c.get[String]("token_type")
expire <- c.get[Option[Long]]("expires_in")
rtoken <- c.get[Option[String]]("refresh_token")
rexpire <- c.get[Option[Long]]("refresh_expires_in")
sstate <- c.get[Option[String]]("session_state")
scope <- c.get[Option[String]]("scope")
} yield AccessToken(atoken, ttype, expire, rtoken, rexpire, sstate, scope)
}
}

View File

@ -0,0 +1,179 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import docspell.common._
import io.circe.Json
import org.http4s.Method._
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.client.Client
import org.http4s.client.dsl.Http4sClientDsl
import org.http4s.client.middleware.RequestLogger
import org.http4s.client.middleware.ResponseLogger
import org.http4s.headers.Accept
import org.http4s.headers.Authorization
import org.log4s.getLogger
/** https://openid.net/specs/openid-connect-core-1_0.html (OIDC)
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest (OIDC)
* https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4 (OAuth2)
* https://datatracker.ietf.org/doc/html/rfc7519 (JWT)
*/
object CodeFlow {
private[this] val log4sLogger = getLogger
def apply[F[_]: Async, A](
client: Client[F],
cfg: ProviderConfig,
redirectUri: String
)(
code: String
): OptionT[F, Json] = {
val logger = Logger.log4s[F](log4sLogger)
val dsl = new Http4sClientDsl[F] {}
val c = logRequests[F](logResponses[F](client))
for {
_ <- OptionT.liftF(
logger.debug(
s"Obtaining access_token for provider ${cfg.providerId.id} and code $code"
)
)
token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code)
_ <- OptionT.liftF(
logger.debug(
s"Obtaining user-info for provider ${cfg.providerId.id} and token $token"
)
)
user <- cfg.userUrl match {
case Some(url) if cfg.signKey.isEmpty =>
fetchFromUserEndpoint[F](c, dsl, url, token)
case _ if cfg.signKey.nonEmpty =>
token.decodeToken(cfg.signKey, cfg.sigAlgo) match {
case Right(jwt) =>
OptionT.pure[F](jwt.claims)
case Left(err) =>
OptionT
.liftF(logger.error(s"Error verifying jwt access token: $err"))
.flatMap(_ => OptionT.none[F, Json])
}
case _ =>
OptionT
.liftF(
logger.error(
s"No signature specified and no user endpoint url. Cannot obtain user info from access token!"
)
)
.flatMap(_ => OptionT.none[F, Json])
}
} yield user
}
/** Using the code that was given by the authentication providers redirect request, get
* the access token. It returns the raw response only json-decoded into a data
* structure. If something fails, it is logged ant None is returned
*
* See https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest
*/
def fetchAccessToken[F[_]: Async](
c: Client[F],
dsl: Http4sClientDsl[F],
cfg: ProviderConfig,
redirectUri: String,
code: String
): OptionT[F, AccessToken] = {
import dsl._
val logger = Logger.log4s[F](log4sLogger)
val req = POST(
UrlForm(
"client_id" -> cfg.clientId,
"client_secret" -> cfg.clientSecret,
"code" -> code,
"grant_type" -> "authorization_code",
"redirect_uri" -> redirectUri
),
Uri.unsafeFromString(cfg.tokenUrl.asString),
Accept(MediaType.application.json)
)
OptionT(c.run(req).use {
case Status.Successful(r) =>
for {
token <- r.attemptAs[AccessToken].value
_ <- token match {
case Right(t) =>
logger.debug(s"Got token response: $t")
case Left(err) =>
logger.error(err)(s"Error decoding access token: ${err.getMessage}")
}
} yield token.toOption
case r =>
logger
.error(s"Error obtaining access token '${r.status.code}' / ${r.as[String]}")
.map(_ => None)
})
}
/** Fetches user info by using a request against the userinfo endpoint. */
def fetchFromUserEndpoint[F[_]: Async](
c: Client[F],
dsl: Http4sClientDsl[F],
endpointUrl: LenientUri,
token: AccessToken
): OptionT[F, Json] = {
import dsl._
val logger = Logger.log4s[F](log4sLogger)
val req = GET(
Uri.unsafeFromString(endpointUrl.asString),
Authorization(Credentials.Token(AuthScheme.Bearer, token.accessToken)),
Accept(MediaType.application.json)
)
val resp: F[Option[Json]] = c.run(req).use {
case Status.Successful(r) =>
for {
json <- r.attemptAs[Json].value
_ <- json match {
case Right(j) =>
logger.trace(s"Got user info: ${j.noSpaces}")
case Left(err) =>
logger.error(err)(s"Error decoding user info response into json!")
}
} yield json.toOption
case r =>
r.as[String]
.flatMap(err =>
logger.error(s"Cannot obtain user info: ${r.status.code} / $err")
)
.map(_ => None)
}
OptionT(resp)
}
private def logRequests[F[_]: Async](c: Client[F]): Client[F] =
RequestLogger(
logHeaders = true,
logBody = true,
logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg))
)(c)
private def logResponses[F[_]: Async](c: Client[F]): Client[F] =
ResponseLogger(
logHeaders = true,
logBody = true,
logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg))
)(c)
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import docspell.common._
import org.http4s.Request
trait CodeFlowConfig[F[_]] {
/** Return the URL to the path where the `CodeFlowRoutes` are mounted. This is used to
* construct the redirect url.
*/
def getEndpointUrl(req: Request[F]): LenientUri
/** Multiple authentication providers are supported, each has its own id. For a given
* id, return the config to use.
*/
def findProvider(id: Ident): Option[ProviderConfig]
}
object CodeFlowConfig {
def apply[F[_]](
url: Request[F] => LenientUri,
provider: Ident => Option[ProviderConfig]
): CodeFlowConfig[F] =
new CodeFlowConfig[F] {
def getEndpointUrl(req: Request[F]): LenientUri = url(req)
def findProvider(id: Ident): Option[ProviderConfig] = provider(id)
}
private[oidc] def resumeUri[F[_]](
req: Request[F],
prov: ProviderConfig,
cfg: CodeFlowConfig[F]
): LenientUri =
cfg.getEndpointUrl(req) / prov.providerId.id / "resume"
}

View File

@ -0,0 +1,95 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import docspell.common._
import org.http4s.HttpRoutes
import org.http4s._
import org.http4s.client.Client
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.Location
import org.log4s.getLogger
object CodeFlowRoutes {
private[this] val log4sLogger = getLogger
def apply[F[_]: Async, A](
onUserInfo: OnUserInfo[F],
config: CodeFlowConfig[F],
client: Client[F]
): HttpRoutes[F] = {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
val logger = Logger.log4s[F](log4sLogger)
HttpRoutes.of[F] {
case req @ GET -> Root / Ident(id) =>
config.findProvider(id) match {
case Some(cfg) =>
val uri = cfg.authorizeUrl
.withQuery("client_id", cfg.clientId)
.withQuery("scope", cfg.scope)
.withQuery(
"redirect_uri",
CodeFlowConfig.resumeUri(req, cfg, config).asString
)
.withQuery("response_type", "code")
logger.debug(
s"Redirecting to OAuth 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'") *>
NotFound()
}
case req @ GET -> Root / Ident(id) / "resume" =>
config.findProvider(id) match {
case None =>
logger.debug(s"No oauth provider found with id '$id'") *>
NotFound()
case Some(provider) =>
val codeFromReq = OptionT.fromOption[F](req.params.get("code"))
val userInfo = for {
_ <- OptionT.liftF(logger.info(s"Resume OAuth/OIDC flow for ${id.id}"))
code <- codeFromReq
_ <- OptionT.liftF(
logger.debug(
s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code"
)
)
redirectUri = CodeFlowConfig.resumeUri(req, provider, config)
u <- CodeFlow(client, provider, redirectUri.asString)(code)
} yield u
userInfo.value.flatMap {
case t @ Some(_) =>
onUserInfo.handle(req, provider, t)
case None =>
val reason = req.params
.get("error")
.map { err =>
val descr =
req.params.get("error_description").map(s => s" ($s)").getOrElse("")
s"$err$descr"
}
.map(err => s": $err")
.getOrElse("")
logger.error(s"Error resuming code flow from '${id.id}'$reason") *>
onUserInfo.handle(req, provider, None)
}
}
}
}
}

View File

@ -0,0 +1,22 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import io.circe.{Decoder, Json}
import scodec.bits.Bases.Alphabets
import scodec.bits.ByteVector
case class Jwt(header: Json, claims: Json, signature: ByteVector) {
def claimsAs[A: Decoder]: Either[String, A] =
claims.as[A].left.map(_.getMessage())
}
object Jwt {
private[oidc] def create(t: (Json, Json, String)): Jwt =
Jwt(t._1, t._2, ByteVector.fromValidBase64(t._3, Alphabets.Base64UrlNoPad))
}

View File

@ -0,0 +1,68 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import cats.effect._
import cats.implicits._
import fs2.Stream
import docspell.common.Logger
import io.circe.Json
import org.http4s._
import org.http4s.headers.`Content-Type`
import org.http4s.implicits._
import org.log4s.getLogger
/** Once the authentication flow is completed, we get "some" json structure that contains
* a claim about the user. From here it's to the user of this small library to complete
* the request.
*
* Usually the json is searched for an account name and the account is then created in
* the application, if it not already exists. The concrete response is up to the
* application, the OAuth/OpenID Connect is done (successfully) at this point.
*/
trait OnUserInfo[F[_]] {
/** Create a response given the request and the obtained user info data. The `userInfo`
* may be retrieved from an JWT token or it is the response of querying the user-info
* endpoint, depending on the configuration provided to `CodeFlowRoutes`. In the latter
* case, the authorization server validated the token.
*
* If `userInfo` is empty, then some error occurred during the flow. The exact error
* has been logged, but it is not given here.
*/
def handle(
req: Request[F],
provider: ProviderConfig,
userInfo: Option[Json]
): F[Response[F]]
}
object OnUserInfo {
private[this] val log = getLogger
def apply[F[_]](
f: (Request[F], ProviderConfig, Option[Json]) => F[Response[F]]
): OnUserInfo[F] =
(req: Request[F], cfg: ProviderConfig, userInfo: Option[Json]) =>
f(req, cfg, userInfo)
def logInfo[F[_]: Sync]: OnUserInfo[F] =
OnUserInfo((_, _, json) =>
Logger
.log4s(log)
.info(s"Got data: ${json.map(_.spaces2)}")
.map(_ =>
Response[F](Status.Ok)
.withContentType(`Content-Type`(mediaType"application/json"))
.withBodyStream(
Stream.emits(json.getOrElse(Json.obj()).spaces2.getBytes.toSeq)
)
)
)
}

View File

@ -0,0 +1,16 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import org.http4s.HttpRoutes
import org.http4s.client.Client
object OpenidConnect {
def codeFlow[F[_]](client: Client[F]): HttpRoutes[F] =
???
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import docspell.common._
import scodec.bits.ByteVector
final case class ProviderConfig(
providerId: Ident,
clientId: String,
clientSecret: String,
scope: String,
authorizeUrl: LenientUri,
tokenUrl: LenientUri,
userUrl: Option[LenientUri],
signKey: ByteVector,
sigAlgo: SignatureAlgo
)
object ProviderConfig {
def github(clientId: String, clientSecret: String) =
ProviderConfig(
Ident.unsafe("github"),
clientId,
clientSecret,
"profile",
LenientUri.unsafe("https://github.com/login/oauth/authorize"),
LenientUri.unsafe("https://github.com/login/oauth/access_token"),
Some(LenientUri.unsafe("https://api.github.com/user")),
ByteVector.empty,
SignatureAlgo.RS256
)
}

View File

@ -0,0 +1,185 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import java.security.spec.X509EncodedKeySpec
import java.security.{KeyFactory, PublicKey}
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
import cats.data.NonEmptyList
import cats.implicits._
import pdi.jwt.{JwtAlgorithm, JwtCirce}
import scodec.bits.ByteVector
sealed trait SignatureAlgo { self: Product =>
def name: String =
self.productPrefix
}
object SignatureAlgo {
case object RS256 extends SignatureAlgo
case object RS384 extends SignatureAlgo
case object RS512 extends SignatureAlgo
case object ES256 extends SignatureAlgo
case object ES384 extends SignatureAlgo
case object ES512 extends SignatureAlgo
case object Ed25519 extends SignatureAlgo
case object HMD5 extends SignatureAlgo
case object HS224 extends SignatureAlgo
case object HS256 extends SignatureAlgo
case object HS384 extends SignatureAlgo
case object HS512 extends SignatureAlgo
val all: NonEmptyList[SignatureAlgo] =
NonEmptyList.of(
RS256,
RS384,
RS512,
ES256,
ES384,
ES512,
Ed25519,
HMD5,
HS224,
HS256,
HS384,
HS512
)
def fromString(str: String): Either[String, SignatureAlgo] =
str.toUpperCase() match {
case "RS256" => Right(RS256)
case "RS384" => Right(RS384)
case "RS512" => Right(RS512)
case "ES256" => Right(ES256)
case "ES384" => Right(ES384)
case "ES512" => Right(ES512)
case "ED25519" => Right(Ed25519)
case "HMD5" => Right(HMD5)
case "HS224" => Right(HS224)
case "HS256" => Right(HS256)
case "HS384" => Right(HS384)
case "HS512" => Right(HS512)
case _ => Left(s"Unknown signature algo: $str")
}
def unsafeFromString(str: String): SignatureAlgo =
fromString(str).fold(sys.error, identity)
private[oidc] def decoder(
sigKey: ByteVector,
algo: SignatureAlgo
): String => Either[Throwable, Jwt] = { token =>
algo match {
case RS256 =>
for {
pubKey <- createPublicKey(sigKey, "RSA")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS256))
.toEither
} yield Jwt.create(decoded)
case RS384 =>
for {
pubKey <- createPublicKey(sigKey, "RSA")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS384))
.toEither
} yield Jwt.create(decoded)
case RS512 =>
for {
pubKey <- createPublicKey(sigKey, "RSA")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS512))
.toEither
} yield Jwt.create(decoded)
case ES256 =>
for {
pubKey <- createPublicKey(sigKey, "EC")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES256))
.toEither
} yield Jwt.create(decoded)
case ES384 =>
for {
pubKey <- createPublicKey(sigKey, "EC")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES384))
.toEither
} yield Jwt.create(decoded)
case ES512 =>
for {
pubKey <- createPublicKey(sigKey, "EC")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES512))
.toEither
} yield Jwt.create(decoded)
case Ed25519 =>
for {
pubKey <- createPublicKey(sigKey, "EdDSA")
decoded <- JwtCirce
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.Ed25519))
.toEither
} yield Jwt.create(decoded)
case HMD5 =>
for {
key <- createSecretKey(sigKey, JwtAlgorithm.HMD5.fullName)
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HMD5)).toEither
} yield Jwt.create(decoded)
case HS224 =>
for {
key <- createSecretKey(sigKey, JwtAlgorithm.HS224.fullName)
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS224)).toEither
} yield Jwt.create(decoded)
case HS256 =>
for {
key <- createSecretKey(sigKey, JwtAlgorithm.HS256.fullName)
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS256)).toEither
} yield Jwt.create(decoded)
case HS384 =>
for {
key <- createSecretKey(sigKey, JwtAlgorithm.HS384.fullName)
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS384)).toEither
} yield Jwt.create(decoded)
case HS512 =>
for {
key <- createSecretKey(sigKey, JwtAlgorithm.HS512.fullName)
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS512)).toEither
} yield Jwt.create(decoded)
}
}
private def createSecretKey(
key: ByteVector,
keyAlgo: String
): Either[Throwable, SecretKey] =
Either.catchNonFatal(new SecretKeySpec(key.toArray, keyAlgo))
private def createPublicKey(
key: ByteVector,
keyAlgo: String
): Either[Throwable, PublicKey] =
Either.catchNonFatal {
val spec = new X509EncodedKeySpec(key.toArray)
KeyFactory.getInstance(keyAlgo).generatePublic(spec)
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.oidc
import docspell.common.Ident
import io.circe.{Decoder, DecodingFailure}
/** Helpers for implementing `OnUserInfo`. */
object UserInfoDecoder {
/** Find the value for `preferred_username` standard claim (see
* https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims).
*/
def preferredUsername: Decoder[Ident] =
findSomeId("preferred_username")
/** 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] =
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] =
Ident(uid.filter(Ident.chars.contains))
.flatMap(id =>
if (id.nonEmpty) Right(id) else Left(s"Id '$uid' empty after normalizing!'")
)
}

View File

@ -61,6 +61,44 @@ docspell.server {
}
}
# Configures OpenID Connect or OAuth2 authentication. Only the
# "Authorization Code Flow" is supported.
#
# When using OpenID Connect, a scope is mandatory.
# TODO
#
# Below are examples for OpenID Connect (keycloak) and OAuth2
# (github).
openid =
[ { enabled = false,
provider = {
provider-id = "keycloak",
client-id = "docspell",
client-secret = "21cd4550-6328-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",
#User URL is not used when signature key is set.
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
sig-algo = "RS512"
}
},
{ enabled = false,
provider = {
provider-id = "github",
client-id = "<your github client id>",
client-secret = "<your github client secret>",
scope = "",
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 = ""
sig-algo = "RS256" #unused but must be set to something
}
}
]
# This endpoint allows to upload files to any collective. The
# intention is that local software integrates with docspell more
# easily. Therefore the endpoint is not protected by the usual

View File

@ -10,6 +10,8 @@ import docspell.backend.auth.Login
import docspell.backend.{Config => BackendConfig}
import docspell.common._
import docspell.ftssolr.SolrConfig
import docspell.oidc.ProviderConfig
import docspell.restserver.Config.OpenIdConfig
import com.comcast.ip4s.IpAddress
@ -25,7 +27,8 @@ case class Config(
maxItemPageSize: Int,
maxNoteLength: Int,
fullTextSearch: Config.FullTextSearch,
adminEndpoint: Config.AdminEndpoint
adminEndpoint: Config.AdminEndpoint,
openid: List[OpenIdConfig]
)
object Config {
@ -70,4 +73,6 @@ object Config {
object FullTextSearch {}
final case class OpenIdConfig(enabled: Boolean, provider: ProviderConfig)
}

View File

@ -8,6 +8,7 @@ package docspell.restserver
import docspell.backend.signup.{Config => SignupConfig}
import docspell.common.config.Implicits._
import docspell.oidc.SignatureAlgo
import pureconfig._
import pureconfig.generic.auto._
@ -21,5 +22,8 @@ object ConfigFile {
object Implicits {
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
ConfigReader[String].emap(reason(SignupConfig.Mode.fromString))
implicit val sigAlgoReader: ConfigReader[SignatureAlgo] =
ConfigReader[String].emap(reason(SignatureAlgo.fromString))
}
}

View File

@ -12,12 +12,16 @@ import fs2.Stream
import docspell.backend.auth.AuthToken
import docspell.common._
import docspell.oidc.CodeFlowRoutes
import docspell.restserver.auth.OpenId
import docspell.restserver.http4s.EnvMiddleware
import docspell.restserver.routes._
import docspell.restserver.webapp._
import org.http4s._
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.client.Client
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.Location
import org.http4s.implicits._
@ -33,9 +37,10 @@ object RestServer {
restApp <-
RestAppImpl
.create[F](cfg, pools.connectEC, pools.httpClientEC)
httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource
httpApp = Router(
"/api/info" -> routes.InfoRoutes(),
"/api/v1/open/" -> openRoutes(cfg, restApp),
"/api/v1/open/" -> openRoutes(cfg, httpClient, restApp),
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
securedRoutes(cfg, restApp, token)
},
@ -98,8 +103,17 @@ object RestServer {
"clientSettings" -> ClientSettingsRoutes(restApp.backend, token)
)
def openRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
def openRoutes[F[_]: Async](
cfg: Config,
client: Client[F],
restApp: RestApp[F]
): HttpRoutes[F] =
Router(
"auth/oauth" -> CodeFlowRoutes(
OpenId.handle[F](restApp.backend, cfg),
OpenId.codeFlowConfig(cfg),
client
),
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
"signup" -> RegisterRoutes(restApp.backend, cfg),
"upload" -> UploadRoutes.open(restApp.backend, cfg),

View File

@ -0,0 +1,57 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.restserver.auth
import cats.effect._
import docspell.backend.BackendApp
import docspell.oidc.{CodeFlowConfig, OnUserInfo}
import docspell.restserver.Config
import docspell.restserver.http4s.ClientRequestInfo
object OpenId {
def codeFlowConfig[F[_]](config: Config): CodeFlowConfig[F] =
CodeFlowConfig(
req =>
ClientRequestInfo
.getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "oauth",
id =>
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) =>
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)
}
)
// 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)
// }
}

View File

@ -23,6 +23,7 @@ object Dependencies {
val Icu4jVersion = "69.1"
val javaOtpVersion = "0.3.0"
val JsoupVersion = "1.14.2"
val JwtScalaVersion = "9.0.1"
val KindProjectorVersion = "0.10.3"
val KittensVersion = "2.3.2"
val LevigoJbig2Version = "2.0"
@ -48,6 +49,10 @@ object Dependencies {
val JQueryVersion = "3.5.1"
val ViewerJSVersion = "0.5.9"
val jwtScala = Seq(
"com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion
)
val scodecBits = Seq(
"org.scodec" %% "scodec-bits" % ScodecBitsVersion
)