mirror of
https://github.com/digital-asset/daml.git
synced 2024-09-19 16:57:40 +03:00
[Oauth2-Middleware] Changes to introduce HOCON+pureconfig for oauth2-middleware (#12061)
* Changes to introduce HOCON+pureconfig for oauth2-middleware CHANGELOG_BEGIN CHANGELOG_END * remove Cli arg parsing code + cleanup based on code review * addition of a minimal config and changes to README.md * keep existing cli args, but load from config file if provided * fix broken docs build * make tests OS independent * Fail/error on supplying both config file and cli opts for startup, address code review comments
This commit is contained in:
parent
e52469c1dc
commit
4745768ad4
@ -15,10 +15,10 @@ OAuth 2.0 Configuration
|
||||
`RFC 6749 <https://tools.ietf.org/html/rfc6749#section-3>`_ specifies that OAuth 2.0 providers offer two endpoints:
|
||||
The `authorization endpoint <https://tools.ietf.org/html/rfc6749#section-3.1>`_
|
||||
and the `token endpoint <https://tools.ietf.org/html/rfc6749#section-3.2>`_.
|
||||
The URIs for these endpoints can be configured independently using the following flags:
|
||||
The URIs for these endpoints can be configured independently using the following fields:
|
||||
|
||||
- ``--oauth-auth``
|
||||
- ``--oauth-token``
|
||||
- ``oauth-auth``
|
||||
- ``oauth-token``
|
||||
|
||||
The OAuth 2.0 provider may require that the application identify itself using a client identifier and client secret.
|
||||
These can be specified using the following environment variables:
|
||||
@ -42,9 +42,9 @@ Authorization Request
|
||||
=====================
|
||||
|
||||
This template defines the format of the `Authorization request <https://tools.ietf.org/html/rfc6749#section-4.1.1>`_.
|
||||
Use the following command-line flag to use a custom template:
|
||||
Use the following config field to use a custom template:
|
||||
|
||||
- ``--oauth-auth-template``
|
||||
- ``oauth-auth-template``
|
||||
|
||||
Arguments
|
||||
^^^^^^^^^
|
||||
@ -93,9 +93,9 @@ Token Request
|
||||
=============
|
||||
|
||||
This template defines the format of the `Token request <https://tools.ietf.org/html/rfc6749#section-4.1.3>`_.
|
||||
Use the following command-line flag to use a custom template:
|
||||
Use the following config field to use a custom template:
|
||||
|
||||
- ``--oauth-token-template``
|
||||
- ``oauth-token-template``
|
||||
|
||||
Arguments
|
||||
^^^^^^^^^
|
||||
@ -131,9 +131,9 @@ Refresh Request
|
||||
===============
|
||||
|
||||
This template defines the format of the `Refresh request <https://tools.ietf.org/html/rfc6749#section-6>`_.
|
||||
Use the following command-line flag to use a custom template:
|
||||
Use the following config field to use a custom template:
|
||||
|
||||
- ``--oauth-refresh-template``
|
||||
- ``oauth-refresh-template``
|
||||
|
||||
Arguments
|
||||
^^^^^^^^^
|
||||
@ -187,12 +187,76 @@ For example, assuming the following nginx configuration snippet:
|
||||
|
||||
You would invoke the OAuth 2.0 auth middleware with the following flags:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
oauth2-middleware \
|
||||
--config oauth-middleware.conf
|
||||
|
||||
The required config would look like
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
{
|
||||
// Environment variables:
|
||||
// DAML_CLIENT_ID The OAuth2 client-id - must not be empty
|
||||
// DAML_CLIENT_SECRET The OAuth2 client-secret - must not be empty
|
||||
client-id = ${DAML_CLIENT_ID}
|
||||
client-secret = ${DAML_CLIENT_SECRET}
|
||||
|
||||
//IP address that OAuth2 Middleware service listens on. Defaults to 127.0.0.1.
|
||||
address = "127.0.0.1"
|
||||
//OAuth2 Middleware service port number. Defaults to 3000. A port number of 0 will let the system pick an ephemeral port. Consider specifying `--port-file` option with port number 0.
|
||||
port = 3000
|
||||
|
||||
//URI to the auth middleware's callback endpoint `/cb`. By default constructed from the incoming login request.
|
||||
callback-uri = "https://example.com/auth/cb"
|
||||
|
||||
//Maximum number of simultaneously pending login requests. Requests will be denied when exceeded until earlier requests have been completed or timed out.
|
||||
max-login-requests = 250
|
||||
|
||||
//Login request timeout. Requests will be evicted if the callback endpoint receives no corresponding request in time.
|
||||
login-timeout = 60s
|
||||
|
||||
//Enable the Secure attribute on the cookie that stores the token. Defaults to true. Only disable this for testing and development purposes.
|
||||
cookie-secure = "true"
|
||||
|
||||
//URI of the OAuth2 authorization endpoint
|
||||
oauth-auth="https://oauth2-provider.com/auth_uri"
|
||||
|
||||
//URI of the OAuth2 token endpoint
|
||||
oauth-token="https://oauth2-provider.com/token_uri"
|
||||
|
||||
//OAuth2 authorization request Jsonnet template
|
||||
oauth-auth-template="file://path/oauth/auth/template"
|
||||
|
||||
//OAuth2 token request Jsonnet template
|
||||
oauth-token-template = "file://path/oauth/token/template"
|
||||
|
||||
//OAuth2 refresh request Jsonnet template
|
||||
oauth-refresh-template = "file://path/oauth/refresh/template"
|
||||
|
||||
// Enables JWT-based authorization, where the JWT is signed by one of the below Jwt based token verifiers
|
||||
token-verifier {
|
||||
// type can be rs256-crt, es256-crt, es512-crt or rs256-jwks
|
||||
type = "rs256-jwks"
|
||||
// X509 certificate file (.crt)/JWKS url from where the public key is loaded
|
||||
uri = "https://example.com/.well-known/jwks.json"
|
||||
}
|
||||
}
|
||||
|
||||
The oauth2-middleware can also be started using cli-args.
|
||||
|
||||
.. note:: Configuration file is the recommended way to run oauth2-middleware, running via cli-args is now deprecated
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
oauth2-middleware \
|
||||
--callback https://example.com/auth/cb \
|
||||
--address localhost
|
||||
--http-port 3000
|
||||
--address localhost \
|
||||
--http-port 3000 \
|
||||
--oauth-auth https://oauth2-provider.com/auth_uri \
|
||||
--oauth-token https://oauth2-provider.com/token_uri \
|
||||
--auth-jwt-rs256-jwks https://example.com/.well-known/jwks.json
|
||||
|
||||
Some browsers reject ``Secure`` cookies on unencrypted connections even on localhost.
|
||||
You can pass the command-line flag ``--cookie-secure no`` for testing and development on localhost to avoid this.
|
||||
You can pass the command-line flag ``--cookie-secure no`` for testing and development on localhost to avoid this.
|
@ -71,6 +71,9 @@ da_scala_library(
|
||||
"@maven//:com_typesafe_akka_akka_parsing",
|
||||
"@maven//:com_typesafe_akka_akka_stream",
|
||||
"@maven//:com_typesafe_scala_logging_scala_logging",
|
||||
"@maven//:com_chuusai_shapeless",
|
||||
"@maven//:com_github_pureconfig_pureconfig_core",
|
||||
"@maven//:com_github_pureconfig_pureconfig_generic",
|
||||
"@maven//:io_spray_spray_json",
|
||||
"@maven//:org_scala_lang_modules_scala_collection_compat",
|
||||
"@maven//:org_scalaz_scalaz_core",
|
||||
@ -87,6 +90,8 @@ da_scala_library(
|
||||
"//ledger/cli-opts",
|
||||
"//ledger/ledger-api-auth",
|
||||
"//libs-scala/ports",
|
||||
"@maven//:com_auth0_java_jwt",
|
||||
"@maven//:com_typesafe_config",
|
||||
"@maven//:org_slf4j_slf4j_api",
|
||||
],
|
||||
)
|
||||
@ -173,6 +178,10 @@ da_scala_test(
|
||||
da_scala_test(
|
||||
name = "oauth2-middleware-tests",
|
||||
srcs = glob(["src/test/scala/com/daml/auth/middleware/oauth2/**/*.scala"]),
|
||||
data = [
|
||||
":src/test/resources/oauth2-middleware.conf",
|
||||
":src/test/resources/oauth2-middleware-minimal.conf",
|
||||
],
|
||||
scala_deps = [
|
||||
"@maven//:com_typesafe_akka_akka_actor",
|
||||
"@maven//:com_typesafe_akka_akka_http",
|
||||
@ -194,6 +203,7 @@ da_scala_test(
|
||||
":oauth2-api",
|
||||
":oauth2-middleware",
|
||||
":oauth2-test-server",
|
||||
"//bazel_tools/runfiles:scala_runfiles",
|
||||
"//daml-lf/data",
|
||||
"//language-support/scala/bindings",
|
||||
"//ledger-api/rs-grpc-bridge",
|
||||
|
@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.daml.auth.middleware.oauth2
|
||||
|
||||
import akka.http.scaladsl.model.Uri
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import com.daml.auth.middleware.oauth2.Config.{
|
||||
DefaultCookieSecure,
|
||||
DefaultHttpPort,
|
||||
DefaultLoginTimeout,
|
||||
DefaultMaxLoginRequests,
|
||||
}
|
||||
import com.daml.cliopts
|
||||
import com.daml.jwt.{
|
||||
ECDSAVerifier,
|
||||
HMAC256Verifier,
|
||||
JwksVerifier,
|
||||
JwtVerifierBase,
|
||||
JwtVerifierConfigurationCli,
|
||||
RSA256Verifier,
|
||||
}
|
||||
import com.typesafe.scalalogging.StrictLogging
|
||||
import pureconfig.{ConfigReader, ConfigSource, ConvertHelpers}
|
||||
import pureconfig.error.ConfigReaderException
|
||||
import scopt.OptionParser
|
||||
|
||||
import java.io.File
|
||||
import pureconfig.generic.semiauto._
|
||||
|
||||
import java.nio.file.{Path, Paths}
|
||||
import scala.concurrent.duration
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
|
||||
sealed trait ConfigError extends Product with Serializable {
|
||||
def msg: String
|
||||
}
|
||||
case object MissingConfigError extends ConfigError {
|
||||
val msg = "Missing auth middleware config file"
|
||||
}
|
||||
final case class ConfigParseError(msg: String) extends ConfigError
|
||||
|
||||
final case class Cli(
|
||||
configFile: Option[File] = None,
|
||||
// Host and port the middleware listens on
|
||||
address: String = cliopts.Http.defaultAddress,
|
||||
port: Int = DefaultHttpPort,
|
||||
portFile: Option[Path] = None,
|
||||
// The URI to which the OAuth2 server will redirect after a completed login flow.
|
||||
// Must map to the `/cb` endpoint of the auth middleware.
|
||||
callbackUri: Option[Uri] = None,
|
||||
maxLoginRequests: Int = DefaultMaxLoginRequests,
|
||||
loginTimeout: FiniteDuration = DefaultLoginTimeout,
|
||||
cookieSecure: Boolean = DefaultCookieSecure,
|
||||
// OAuth2 server endpoints
|
||||
oauthAuth: Uri,
|
||||
oauthToken: Uri,
|
||||
// OAuth2 server request templates
|
||||
oauthAuthTemplate: Option[Path],
|
||||
oauthTokenTemplate: Option[Path],
|
||||
oauthRefreshTemplate: Option[Path],
|
||||
// OAuth2 client properties
|
||||
clientId: String,
|
||||
clientSecret: SecretString,
|
||||
// Token verification
|
||||
tokenVerifier: JwtVerifierBase,
|
||||
) {
|
||||
|
||||
import Cli._
|
||||
|
||||
def loadConfigFromFile: Either[ConfigError, Config] = {
|
||||
require(configFile.nonEmpty, "Config file should be defined to load app config")
|
||||
configFile
|
||||
.map(f =>
|
||||
try {
|
||||
Right(ConfigSource.file(f).loadOrThrow[Config])
|
||||
} catch {
|
||||
case ex: ConfigReaderException[_] => Left(ConfigParseError(ex.failures.head.description))
|
||||
}
|
||||
)
|
||||
.get
|
||||
}
|
||||
|
||||
def loadConfigFromCliArgs: Config = {
|
||||
val cfg = Config(
|
||||
address,
|
||||
port,
|
||||
portFile,
|
||||
callbackUri,
|
||||
maxLoginRequests,
|
||||
loginTimeout,
|
||||
cookieSecure,
|
||||
oauthAuth,
|
||||
oauthToken,
|
||||
oauthAuthTemplate,
|
||||
oauthTokenTemplate,
|
||||
oauthRefreshTemplate,
|
||||
clientId,
|
||||
clientSecret,
|
||||
tokenVerifier,
|
||||
)
|
||||
cfg.validate
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
object Cli extends StrictLogging {
|
||||
implicit val tokenVerifierReader: ConfigReader[JwtVerifierBase] =
|
||||
ConfigReader.forProduct2[JwtVerifierBase, String, String]("type", "uri") {
|
||||
case (t: String, p: String) =>
|
||||
// hs256-unsafe, rs256-crt, es256-crt, es512-crt, rs256-jwks
|
||||
t match {
|
||||
case "hs256-unsafe" =>
|
||||
HMAC256Verifier(p)
|
||||
.valueOr(err => sys.error(s"Failed to create HMAC256 verifier: $err"))
|
||||
case "rs256-crt" =>
|
||||
RSA256Verifier
|
||||
.fromCrtFile(p)
|
||||
.valueOr(err => sys.error(s"Failed to create RSA256 verifier: $err"))
|
||||
case "es256-crt" =>
|
||||
ECDSAVerifier
|
||||
.fromCrtFile(p, Algorithm.ECDSA256(_, null))
|
||||
.valueOr(err => sys.error(s"Failed to create ECDSA256 verifier: $err"))
|
||||
case "es512-crt" =>
|
||||
ECDSAVerifier
|
||||
.fromCrtFile(p, Algorithm.ECDSA512(_, null))
|
||||
.valueOr(err => sys.error(s"Failed to create ECDSA512 verifier: $err"))
|
||||
case "rs256-jwks" =>
|
||||
JwksVerifier(p)
|
||||
}
|
||||
}
|
||||
lazy implicit val uriReader: ConfigReader[Uri] =
|
||||
ConfigReader.fromString[Uri](ConvertHelpers.catchReadError(s => Uri(s)))
|
||||
lazy implicit val clientSecretReader: ConfigReader[SecretString] =
|
||||
ConfigReader.fromString[SecretString](ConvertHelpers.catchReadError(s => SecretString(s)))
|
||||
lazy implicit val cfgReader: ConfigReader[Config] = deriveReader[Config]
|
||||
|
||||
private val Empty =
|
||||
Cli(
|
||||
configFile = None,
|
||||
address = cliopts.Http.defaultAddress,
|
||||
port = DefaultHttpPort,
|
||||
portFile = None,
|
||||
callbackUri = None,
|
||||
maxLoginRequests = DefaultMaxLoginRequests,
|
||||
loginTimeout = DefaultLoginTimeout,
|
||||
cookieSecure = DefaultCookieSecure,
|
||||
oauthAuth = null,
|
||||
oauthToken = null,
|
||||
oauthAuthTemplate = None,
|
||||
oauthTokenTemplate = None,
|
||||
oauthRefreshTemplate = None,
|
||||
clientId = null,
|
||||
clientSecret = null,
|
||||
tokenVerifier = null,
|
||||
)
|
||||
|
||||
private val parser: OptionParser[Cli] = new scopt.OptionParser[Cli]("oauth-middleware") {
|
||||
help('h', "help").text("Print usage")
|
||||
opt[Option[File]]('c', "config")
|
||||
.text(
|
||||
"This is the recommended way to provide an app config file, the remaining cli-args are deprecated"
|
||||
)
|
||||
.valueName("<file>")
|
||||
.action((file, cli) => cli.copy(configFile = file))
|
||||
|
||||
cliopts.Http.serverParse(this, serviceName = "OAuth2 Middleware")(
|
||||
address = (f, c) => c.copy(address = f(c.address)),
|
||||
httpPort = (f, c) => c.copy(port = f(c.port)),
|
||||
defaultHttpPort = Some(DefaultHttpPort),
|
||||
portFile = Some((f, c) => c.copy(portFile = f(c.portFile))),
|
||||
)
|
||||
|
||||
opt[String]("callback")
|
||||
.action((x, c) => c.copy(callbackUri = Some(Uri(x))))
|
||||
.text(
|
||||
"URI to the auth middleware's callback endpoint `/cb`. By default constructed from the incoming login request."
|
||||
)
|
||||
|
||||
opt[Int]("max-pending-login-requests")
|
||||
.action((x, c) => c.copy(maxLoginRequests = x))
|
||||
.text(
|
||||
"Maximum number of simultaneously pending login requests. Requests will be denied when exceeded until earlier requests have been completed or timed out."
|
||||
)
|
||||
|
||||
opt[Boolean]("cookie-secure")
|
||||
.action((x, c) => c.copy(cookieSecure = x))
|
||||
.text(
|
||||
"Enable the Secure attribute on the cookie that stores the token. Defaults to true. Only disable this for testing and development purposes."
|
||||
)
|
||||
|
||||
opt[Long]("login-request-timeout")
|
||||
.action((x, c) => c.copy(loginTimeout = FiniteDuration(x, duration.SECONDS)))
|
||||
.text(
|
||||
"Login request timeout. Requests will be evicted if the callback endpoint receives no corresponding request in time."
|
||||
)
|
||||
|
||||
opt[String]("oauth-auth")
|
||||
.action((x, c) => c.copy(oauthAuth = Uri(x)))
|
||||
.text("URI of the OAuth2 authorization endpoint")
|
||||
|
||||
opt[String]("oauth-token")
|
||||
.action((x, c) => c.copy(oauthToken = Uri(x)))
|
||||
.text("URI of the OAuth2 token endpoint")
|
||||
|
||||
opt[String]("oauth-auth-template")
|
||||
.action((x, c) => c.copy(oauthAuthTemplate = Some(Paths.get(x))))
|
||||
.text("OAuth2 authorization request Jsonnet template")
|
||||
|
||||
opt[String]("oauth-token-template")
|
||||
.action((x, c) => c.copy(oauthTokenTemplate = Some(Paths.get(x))))
|
||||
.text("OAuth2 token request Jsonnet template")
|
||||
|
||||
opt[String]("oauth-refresh-template")
|
||||
.action((x, c) => c.copy(oauthRefreshTemplate = Some(Paths.get(x))))
|
||||
.text("OAuth2 refresh request Jsonnet template")
|
||||
|
||||
opt[String]("id")
|
||||
.hidden()
|
||||
.action((x, c) => c.copy(clientId = x))
|
||||
.withFallback(() => sys.env.getOrElse("DAML_CLIENT_ID", ""))
|
||||
|
||||
opt[String]("secret")
|
||||
.hidden()
|
||||
.action((x, c) => c.copy(clientSecret = SecretString(x)))
|
||||
.withFallback(() => sys.env.getOrElse("DAML_CLIENT_SECRET", ""))
|
||||
|
||||
JwtVerifierConfigurationCli.parse(this)((v, c) => c.copy(tokenVerifier = v))
|
||||
|
||||
checkConfig { cfg =>
|
||||
if (cfg.configFile.isEmpty && cfg.tokenVerifier == null)
|
||||
Left("You must specify one of the --auth-jwt-* flags for token verification.")
|
||||
else
|
||||
Right(())
|
||||
}
|
||||
|
||||
checkConfig { cfg =>
|
||||
if (cfg.configFile.isEmpty && (cfg.clientId.isEmpty || cfg.clientSecret.value.isEmpty))
|
||||
Left("Environment variable DAML_CLIENT_ID AND DAML_CLIENT_SECRET must not be empty")
|
||||
else
|
||||
Right(())
|
||||
}
|
||||
|
||||
checkConfig { cfg =>
|
||||
if (cfg.configFile.isEmpty && (cfg.oauthAuth == null || cfg.oauthToken == null))
|
||||
Left("oauth-auth and oauth-token values must not be empty")
|
||||
else
|
||||
Right(())
|
||||
}
|
||||
|
||||
checkConfig { cfg =>
|
||||
val cliOptionsAreDefined =
|
||||
cfg.oauthToken != null || cfg.oauthAuth != null || cfg.tokenVerifier != null
|
||||
if (cfg.configFile.isDefined && cliOptionsAreDefined) {
|
||||
Left("Found both config file and cli opts for the app, please provide only one of them")
|
||||
} else Right(())
|
||||
}
|
||||
|
||||
override def showUsageOnError: Option[Boolean] = Some(true)
|
||||
}
|
||||
|
||||
def parse(args: Array[String]): Option[Cli] = parser.parse(args, Empty)
|
||||
|
||||
def parseConfig(args: Array[String]): Option[Config] = {
|
||||
val cli = parse(args)
|
||||
cli.flatMap { c =>
|
||||
if (c.configFile.isDefined) {
|
||||
c.loadConfigFromFile match {
|
||||
case Right(conf) => Some(conf)
|
||||
case Left(err) =>
|
||||
logger.error(s"Unable to start oauth2-middleware using config: ${err.msg}")
|
||||
None
|
||||
}
|
||||
} else {
|
||||
logger.warn("Using cli opts for running oauth2-middleware is deprecated")
|
||||
Some(c.loadConfigFromCliArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -3,26 +3,26 @@
|
||||
|
||||
package com.daml.auth.middleware.oauth2
|
||||
|
||||
import java.nio.file.{Path, Paths}
|
||||
|
||||
import java.nio.file.Path
|
||||
import akka.http.scaladsl.model.Uri
|
||||
import com.daml.auth.middleware.oauth2.Config._
|
||||
import com.daml.cliopts
|
||||
import com.daml.jwt.{JwtVerifierBase, JwtVerifierConfigurationCli}
|
||||
import com.daml.jwt.JwtVerifierBase
|
||||
|
||||
import scala.concurrent.duration
|
||||
import scala.concurrent.duration.FiniteDuration
|
||||
|
||||
case class Config(
|
||||
// Host and port the middleware listens on
|
||||
address: String,
|
||||
port: Int,
|
||||
portFile: Option[Path],
|
||||
address: String = cliopts.Http.defaultAddress,
|
||||
port: Int = DefaultHttpPort,
|
||||
portFile: Option[Path] = None,
|
||||
// The URI to which the OAuth2 server will redirect after a completed login flow.
|
||||
// Must map to the `/cb` endpoint of the auth middleware.
|
||||
callbackUri: Option[Uri],
|
||||
maxLoginRequests: Int,
|
||||
loginTimeout: FiniteDuration,
|
||||
cookieSecure: Boolean,
|
||||
callbackUri: Option[Uri] = None,
|
||||
maxLoginRequests: Int = DefaultMaxLoginRequests,
|
||||
loginTimeout: FiniteDuration = DefaultLoginTimeout,
|
||||
cookieSecure: Boolean = DefaultCookieSecure,
|
||||
// OAuth2 server endpoints
|
||||
oauthAuth: Uri,
|
||||
oauthToken: Uri,
|
||||
@ -35,7 +35,15 @@ case class Config(
|
||||
clientSecret: SecretString,
|
||||
// Token verification
|
||||
tokenVerifier: JwtVerifierBase,
|
||||
)
|
||||
) {
|
||||
def validate: Unit = {
|
||||
require(oauthToken != null, "Oauth token value on config cannot be null")
|
||||
require(oauthAuth != null, "Oauth auth value on config cannot be null")
|
||||
require(clientId.nonEmpty, "DAML_CLIENT_ID cannot be empty")
|
||||
require(clientSecret.value.nonEmpty, "DAML_CLIENT_SECRET cannot be empty")
|
||||
require(tokenVerifier != null, "token verifier must be defined")
|
||||
}
|
||||
}
|
||||
|
||||
case class SecretString(value: String) {
|
||||
override def toString: String = "###"
|
||||
@ -46,120 +54,4 @@ object Config {
|
||||
val DefaultCookieSecure: Boolean = true
|
||||
val DefaultMaxLoginRequests: Int = 100
|
||||
val DefaultLoginTimeout: FiniteDuration = FiniteDuration(5, duration.MINUTES)
|
||||
|
||||
private val Empty =
|
||||
Config(
|
||||
address = cliopts.Http.defaultAddress,
|
||||
port = DefaultHttpPort,
|
||||
portFile = None,
|
||||
callbackUri = None,
|
||||
maxLoginRequests = DefaultMaxLoginRequests,
|
||||
loginTimeout = DefaultLoginTimeout,
|
||||
cookieSecure = DefaultCookieSecure,
|
||||
oauthAuth = null,
|
||||
oauthToken = null,
|
||||
oauthAuthTemplate = None,
|
||||
oauthTokenTemplate = None,
|
||||
oauthRefreshTemplate = None,
|
||||
clientId = null,
|
||||
clientSecret = null,
|
||||
tokenVerifier = null,
|
||||
)
|
||||
|
||||
def parseConfig(args: collection.Seq[String]): Option[Config] =
|
||||
configParser.parse(args, Empty)
|
||||
|
||||
@SuppressWarnings(Array("org.wartremover.warts.NonUnitStatements"))
|
||||
val configParser: scopt.OptionParser[Config] =
|
||||
new scopt.OptionParser[Config]("oauth-middleware") {
|
||||
head("OAuth2 Middleware")
|
||||
|
||||
cliopts.Http.serverParse(this, serviceName = "OAuth2 Middleware")(
|
||||
address = (f, c) => c.copy(address = f(c.address)),
|
||||
httpPort = (f, c) => c.copy(port = f(c.port)),
|
||||
defaultHttpPort = Some(DefaultHttpPort),
|
||||
portFile = Some((f, c) => c.copy(portFile = f(c.portFile))),
|
||||
)
|
||||
|
||||
opt[String]("callback")
|
||||
.action((x, c) => c.copy(callbackUri = Some(Uri(x))))
|
||||
.text(
|
||||
"URI to the auth middleware's callback endpoint `/cb`. By default constructed from the incoming login request."
|
||||
)
|
||||
|
||||
opt[Int]("max-pending-login-requests")
|
||||
.action((x, c) => c.copy(maxLoginRequests = x))
|
||||
.text(
|
||||
"Maximum number of simultaneously pending login requests. Requests will be denied when exceeded until earlier requests have been completed or timed out."
|
||||
)
|
||||
|
||||
opt[Boolean]("cookie-secure")
|
||||
.action((x, c) => c.copy(cookieSecure = x))
|
||||
.text(
|
||||
"Enable the Secure attribute on the cookie that stores the token. Defaults to true. Only disable this for testing and development purposes."
|
||||
)
|
||||
|
||||
opt[Long]("login-request-timeout")
|
||||
.action((x, c) => c.copy(loginTimeout = FiniteDuration(x, duration.SECONDS)))
|
||||
.text(
|
||||
"Login request timeout. Requests will be evicted if the callback endpoint receives no corresponding request in time."
|
||||
)
|
||||
|
||||
opt[String]("oauth-auth")
|
||||
.action((x, c) => c.copy(oauthAuth = Uri(x)))
|
||||
.required()
|
||||
.text("URI of the OAuth2 authorization endpoint")
|
||||
|
||||
opt[String]("oauth-token")
|
||||
.action((x, c) => c.copy(oauthToken = Uri(x)))
|
||||
.required()
|
||||
.text("URI of the OAuth2 token endpoint")
|
||||
|
||||
opt[String]("oauth-auth-template")
|
||||
.action((x, c) => c.copy(oauthAuthTemplate = Some(Paths.get(x))))
|
||||
.text("OAuth2 authorization request Jsonnet template")
|
||||
|
||||
opt[String]("oauth-token-template")
|
||||
.action((x, c) => c.copy(oauthTokenTemplate = Some(Paths.get(x))))
|
||||
.text("OAuth2 token request Jsonnet template")
|
||||
|
||||
opt[String]("oauth-refresh-template")
|
||||
.action((x, c) => c.copy(oauthRefreshTemplate = Some(Paths.get(x))))
|
||||
.text("OAuth2 refresh request Jsonnet template")
|
||||
|
||||
opt[String]("id")
|
||||
.hidden()
|
||||
.action((x, c) => c.copy(clientId = x))
|
||||
.withFallback(() => sys.env.getOrElse("DAML_CLIENT_ID", ""))
|
||||
.validate(x =>
|
||||
if (x.isEmpty) failure("Environment variable DAML_CLIENT_ID must not be empty")
|
||||
else success
|
||||
)
|
||||
|
||||
opt[String]("secret")
|
||||
.hidden()
|
||||
.action((x, c) => c.copy(clientSecret = SecretString(x)))
|
||||
.withFallback(() => sys.env.getOrElse("DAML_CLIENT_SECRET", ""))
|
||||
.validate(x =>
|
||||
if (x.isEmpty) failure("Environment variable DAML_CLIENT_SECRET must not be empty")
|
||||
else success
|
||||
)
|
||||
|
||||
JwtVerifierConfigurationCli.parse(this)((v, c) => c.copy(tokenVerifier = v))
|
||||
|
||||
checkConfig { cfg =>
|
||||
if (cfg.tokenVerifier == null)
|
||||
Left("You must specify one of the --auth-jwt-* flags for token verification.")
|
||||
else
|
||||
Right(())
|
||||
}
|
||||
|
||||
help("help").text("Print this usage text")
|
||||
|
||||
note("""
|
||||
|Environment variables:
|
||||
| DAML_CLIENT_ID The OAuth2 client-id - must not be empty
|
||||
| DAML_CLIENT_SECRET The OAuth2 client-secret - must not be empty
|
||||
""".stripMargin)
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import scala.util.{Failure, Success}
|
||||
|
||||
object Main extends StrictLogging {
|
||||
def main(args: Array[String]): Unit = {
|
||||
Config.parseConfig(args) match {
|
||||
Cli.parseConfig(args) match {
|
||||
case Some(config) => main(config)
|
||||
case None => sys.exit(1)
|
||||
}
|
||||
|
@ -79,17 +79,30 @@ repository](https://github.com/digital-asset/ex-secure-daml-infra).
|
||||
$ DAML_CLIENT_ID=CLIENTID \
|
||||
DAML_CLIENT_SECRET=CLIENTSECRET \
|
||||
bazel run //triggers/service/auth:oauth-middleware-binary -- \
|
||||
--port 3000 \
|
||||
--oauth-auth AUTHURL \
|
||||
--oauth-token TOKENURL
|
||||
--config oauth-middleware.conf
|
||||
```
|
||||
- Replace `CLIENTID` and `CLIENTSECRET` by the "Client ID" and "Client
|
||||
Secret" from above.
|
||||
- Replace `AUTHURL` and `TOKENURL` by the "OAuth Authorization URL"
|
||||
and "OAuth Token URL" from above. They should look as follows:
|
||||
|
||||
The basic minimal config that needs to be supplied needs to have appropriate
|
||||
`callback-uri`,`oauth-auth` and `oauth-token` urls defined,
|
||||
along with the `token-verifier`,`client-id` and `client-secret` fields. e.g
|
||||
```
|
||||
https://XYZ.auth0.com/authorize
|
||||
https://XYZ.auth0.com/oauth/token
|
||||
{
|
||||
callback-uri = "https://example.com/auth/cb"
|
||||
oauth-auth = "https://XYZ.auth0.com/authorize"
|
||||
oauth-token = "https://XYZ.auth0.com/oauth/token"
|
||||
|
||||
client-id = ${DAML_CLIENT_ID}
|
||||
client-secret = ${DAML_CLIENT_SECRET}
|
||||
|
||||
// type can be one of rs256-crt, es256-crt, es512-crt, rs256-jwks
|
||||
// uri is the uri to the cert file or the jwks url
|
||||
token-verifier {
|
||||
type = "rs256-jwks"
|
||||
uri = "https://example.com/.well-known/jwks.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Browse to the middleware's login endpoint.
|
||||
- URL `http://localhost:3000/login?redirect_uri=callback&claims=actAs:Alice`
|
||||
|
@ -0,0 +1,18 @@
|
||||
{
|
||||
callback-uri = "https://example.com/auth/cb"
|
||||
oauth-auth = "https://oauth2/uri"
|
||||
oauth-token = "https://oauth2/token"
|
||||
|
||||
// client-id = ${DAML_CLIENT_ID}
|
||||
// client-secret = ${DAML_CLIENT_SECRET}
|
||||
// can be set via env variables , dummy values for test purposes
|
||||
client-id = foo
|
||||
client-secret = bar
|
||||
|
||||
// type can be one of rs256-crt, es256-crt, es512-crt, rs256-jwks
|
||||
// uri is the uri to the cert file or the jwks url
|
||||
token-verifier {
|
||||
type = "rs256-jwks"
|
||||
uri = "https://example.com/.well-known/jwks.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
{
|
||||
address = "127.0.0.1"
|
||||
port = 3000
|
||||
callback-uri = "https://example.com/auth/cb"
|
||||
max-login-requests = 10
|
||||
login-timeout = 60s
|
||||
cookie-secure = false
|
||||
oauth-auth = "https://oauth2/uri"
|
||||
oauth-token = "https://oauth2/token"
|
||||
|
||||
oauth-auth-template = "auth_template"
|
||||
oauth-token-template = "token_template"
|
||||
oauth-refresh-template = "refresh_template"
|
||||
|
||||
// client-id = ${DAML_CLIENT_ID}
|
||||
// client-secret = ${DAML_CLIENT_SECRET}
|
||||
// can be set via env variables , dummy values for test purposes
|
||||
client-id = foo
|
||||
client-secret = bar
|
||||
|
||||
// type can be one of rs256-crt, es256-crt, es512-crt, rs256-jwks
|
||||
// uri is the uri to the cert file or the jwks url
|
||||
token-verifier {
|
||||
type = "rs256-jwks"
|
||||
uri = "https://example.com/.well-known/jwks.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package com.daml.auth.middleware.oauth2
|
||||
|
||||
import akka.http.scaladsl.model.Uri
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import org.scalatest.wordspec.AsyncWordSpec
|
||||
import com.daml.bazeltools.BazelRunfiles.requiredResource
|
||||
import com.daml.jwt.JwksVerifier
|
||||
|
||||
import java.nio.file.Paths
|
||||
import scala.concurrent.duration._
|
||||
|
||||
class CliSpec extends AsyncWordSpec with Matchers {
|
||||
val confFile = "triggers/service/auth/src/test/resources/oauth2-middleware.conf"
|
||||
def loadCli(file: String): Cli = {
|
||||
Cli.parse(Array("--config", file)).getOrElse(fail("Could not load Cli on parse"))
|
||||
}
|
||||
|
||||
"should pickup the config file provided" in {
|
||||
val file = requiredResource(confFile)
|
||||
val cli = loadCli(file.getAbsolutePath)
|
||||
cli.configFile should not be empty
|
||||
}
|
||||
|
||||
"should take default values on loading minimal config" in {
|
||||
val file =
|
||||
requiredResource("triggers/service/auth/src/test/resources/oauth2-middleware-minimal.conf")
|
||||
val cli = loadCli(file.getAbsolutePath)
|
||||
cli.configFile should not be empty
|
||||
cli.loadConfigFromFile match {
|
||||
case Left(ex) => fail(ex.msg)
|
||||
case Right(c) =>
|
||||
c.address shouldBe "127.0.0.1"
|
||||
c.port shouldBe Config.DefaultHttpPort
|
||||
c.callbackUri shouldBe Some(Uri("https://example.com/auth/cb"))
|
||||
c.maxLoginRequests shouldBe Config.DefaultMaxLoginRequests
|
||||
c.loginTimeout shouldBe Config.DefaultLoginTimeout
|
||||
c.cookieSecure shouldBe Config.DefaultCookieSecure
|
||||
c.oauthAuth shouldBe Uri("https://oauth2/uri")
|
||||
c.oauthToken shouldBe Uri("https://oauth2/token")
|
||||
|
||||
c.oauthAuthTemplate shouldBe None
|
||||
c.oauthTokenTemplate shouldBe None
|
||||
c.oauthRefreshTemplate shouldBe None
|
||||
|
||||
c.clientId shouldBe sys.env.getOrElse("DAML_CLIENT_ID", "foo")
|
||||
c.clientSecret shouldBe SecretString(sys.env.getOrElse("DAML_CLIENT_SECRET", "bar"))
|
||||
|
||||
// token verifier needs to be set.
|
||||
c.tokenVerifier match {
|
||||
case _: JwksVerifier => succeed
|
||||
case _ => fail("expected JwksVerifier based on supplied config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"should be able to successfully load the config based on the file provided" in {
|
||||
val file = requiredResource(confFile)
|
||||
val cli = loadCli(file.getAbsolutePath)
|
||||
cli.configFile should not be empty
|
||||
cli.loadConfigFromFile match {
|
||||
case Left(ex) => fail(ex.msg)
|
||||
case Right(c) =>
|
||||
c.address shouldBe "127.0.0.1"
|
||||
c.port shouldBe 3000
|
||||
c.callbackUri shouldBe Some(Uri("https://example.com/auth/cb"))
|
||||
c.maxLoginRequests shouldBe 10
|
||||
c.loginTimeout shouldBe FiniteDuration(60, SECONDS)
|
||||
c.cookieSecure shouldBe false
|
||||
c.oauthAuth shouldBe Uri("https://oauth2/uri")
|
||||
c.oauthToken shouldBe Uri("https://oauth2/token")
|
||||
|
||||
c.oauthAuthTemplate shouldBe Some(Paths.get("auth_template"))
|
||||
c.oauthTokenTemplate shouldBe Some(Paths.get("token_template"))
|
||||
c.oauthRefreshTemplate shouldBe Some(Paths.get("refresh_template"))
|
||||
|
||||
c.clientId shouldBe sys.env.getOrElse("DAML_CLIENT_ID", "foo")
|
||||
c.clientSecret shouldBe SecretString(sys.env.getOrElse("DAML_CLIENT_SECRET", "bar"))
|
||||
|
||||
c.tokenVerifier match {
|
||||
case _: JwksVerifier => succeed
|
||||
case _ => fail("expected JwksVerifier based on supplied config")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
"parse should raise error on non-existent config file" in {
|
||||
val cli = loadCli("missingFile.conf")
|
||||
cli.configFile should not be empty
|
||||
val cfg = cli.loadConfigFromFile
|
||||
cfg match {
|
||||
case Left(err) => err shouldBe a[ConfigParseError]
|
||||
case _ => fail("Expected a `ConfigParseError` on missing conf file")
|
||||
}
|
||||
|
||||
//parseConfig for non-existent file should return a None
|
||||
Cli.parseConfig(
|
||||
Array(
|
||||
"--config-file",
|
||||
"missingFile.conf",
|
||||
)
|
||||
) shouldBe None
|
||||
}
|
||||
|
||||
"should load config from cli args on missing conf file " in {
|
||||
Cli
|
||||
.parseConfig(
|
||||
Array(
|
||||
"--oauth-auth",
|
||||
"file://foo",
|
||||
"--oauth-token",
|
||||
"file://bar",
|
||||
"--id",
|
||||
"foo",
|
||||
"--secret",
|
||||
"bar",
|
||||
"--auth-jwt-hs256-unsafe",
|
||||
"unsafe",
|
||||
)
|
||||
) should not be empty
|
||||
}
|
||||
|
||||
"should fail to load config from cli args on incomplete cli args" in {
|
||||
Cli
|
||||
.parseConfig(
|
||||
Array(
|
||||
"--oauth-auth",
|
||||
"file://foo",
|
||||
"--id",
|
||||
"foo",
|
||||
"--secret",
|
||||
"bar",
|
||||
"--auth-jwt-hs256-unsafe",
|
||||
"unsafe",
|
||||
)
|
||||
) shouldBe None
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user