next.plugins.auth0passwordless.scala Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of otoroshi_2.12 Show documentation
Show all versions of otoroshi_2.12 Show documentation
Lightweight api management on top of a modern http reverse proxy
The newest version!
package otoroshi.next.plugins
import akka.http.scaladsl.model.Uri
import akka.stream.Materializer
import akka.util.ByteString
import otoroshi.auth.{GenericOauth2Module, GenericOauth2ModuleConfig}
import otoroshi.env.Env
import otoroshi.gateway.Errors
import otoroshi.models.{PrivateAppsUser, PrivateAppsUserHelper}
import otoroshi.next.plugins.api._
import otoroshi.next.proxy.NgProxyEngineError
import otoroshi.security.IdGenerator
import otoroshi.utils.syntax.implicits._
import play.api.Logger
import play.api.libs.json._
import play.api.mvc.Results
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.{Duration, DurationInt}
import scala.concurrent.{ExecutionContext, Future}
import scala.util._
sealed trait Auth0PasswordlessConnectionKind {
def name: String
}
object Auth0PasswordlessConnectionKind {
case object Email extends Auth0PasswordlessConnectionKind { def name: String = "email" }
case object Sms extends Auth0PasswordlessConnectionKind { def name: String = "sms" }
def apply(str: String): Auth0PasswordlessConnectionKind = str.toLowerCase() match {
case "email" => Email
case _ => Sms
}
}
sealed trait Auth0PasswordlessSendKind {
def name: String
}
object Auth0PasswordlessSendKind {
case object Link extends Auth0PasswordlessSendKind { def name: String = "link" }
case object Code extends Auth0PasswordlessSendKind { def name: String = "code" }
def apply(str: String): Auth0PasswordlessSendKind = str.toLowerCase() match {
case "link" => Link
case _ => Code
}
}
case class Auth0PasswordlessAuthConfig(
ref: String,
connection: Auth0PasswordlessConnectionKind,
send: Auth0PasswordlessSendKind,
audience: Option[String]
) extends NgPluginConfig {
override def json: JsValue = Auth0PasswordlessAuthConfig.format.writes(this)
}
object Auth0PasswordlessAuthConfig {
val default =
Auth0PasswordlessAuthConfig("", Auth0PasswordlessConnectionKind.Email, Auth0PasswordlessSendKind.Code, None)
val format = new Format[Auth0PasswordlessAuthConfig] {
override def writes(o: Auth0PasswordlessAuthConfig): JsValue = Json.obj(
"ref" -> o.ref,
"connection" -> o.connection.name,
"send" -> o.send.name,
"audience" -> o.audience.map(_.json).getOrElse(JsNull).asValue
)
override def reads(json: JsValue): JsResult[Auth0PasswordlessAuthConfig] = Try {
Auth0PasswordlessAuthConfig(
ref = json.select("ref").asString,
connection = Auth0PasswordlessConnectionKind.apply(
json.select("connection").asOpt[String].getOrElse(Auth0PasswordlessAuthConfig.default.connection.name)
),
send = Auth0PasswordlessSendKind.apply(
json.select("send").asOpt[String].getOrElse(Auth0PasswordlessAuthConfig.default.send.name)
),
audience = json.select("audience").asOpt[String]
)
} match {
case Failure(e) => JsError(e.getMessage)
case Success(e) => JsSuccess(e)
}
}
val configFlow: Seq[String] = Seq("ref", "connection", "send", "audience")
val configSchema: Option[JsObject] = Some(
Json.obj(
"ref" -> Json.obj(
"type" -> "select",
"label" -> s"Auth. module",
"props" -> Json.obj(
"optionsFrom" -> s"/bo/api/proxy/api/auths",
"optionsTransformer" -> Json.obj(
"label" -> "name",
"value" -> "id"
)
)
),
"connection" -> Json.obj(
"type" -> "select",
"label" -> s"Connection",
"props" -> Json.obj(
"options" -> Json.arr(
Json.obj("label" -> "Email", "value" -> "email"),
Json.obj("label" -> "SMS", "value" -> "sms")
)
)
),
"send" -> Json.obj(
"type" -> "select",
"label" -> s"Send",
"props" -> Json.obj(
"options" -> Json.arr(
Json.obj("label" -> "Code", "value" -> "code"),
Json.obj("label" -> "Link", "value" -> "link")
)
)
),
"audience" -> Json.obj(
"type" -> "string",
"label" -> "audience"
)
)
)
}
class Auth0PasswordlessStartFlowEndpoint extends NgBackendCall {
override def steps: Seq[NgStep] = Seq(NgStep.CallBackend)
override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand
override def multiInstance: Boolean = true
override def core: Boolean = true
override def name: String = "Auth0 Passwordless start flow endpoint"
override def description: Option[String] = "This plugin provide an endpoint to start a passwordless flow".some
override def defaultConfigObject: Option[NgPluginConfig] = Some(Auth0PasswordlessAuthConfig.default)
override def useDelegates: Boolean = false
override def noJsForm: Boolean = true
override def configFlow: Seq[String] = Auth0PasswordlessAuthConfig.configFlow
override def configSchema: Option[JsObject] = Auth0PasswordlessAuthConfig.configSchema
def doStartFlow(
ctx: NgbBackendCallContext,
params: JsObject,
config: Auth0PasswordlessAuthConfig,
oauthConfig: GenericOauth2ModuleConfig
)(implicit
env: Env,
ec: ExecutionContext,
mat: Materializer
): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
val domain = Uri(oauthConfig.authorizeUrl).authority.host.toString()
val state = IdGenerator.uuid
val payload = Json
.obj(
"client_id" -> oauthConfig.clientId,
"client_secret" -> oauthConfig.clientSecret,
"connection" -> config.connection.name,
"send" -> config.send.name,
"authParams" -> Json.obj(
"scope" -> "openid",
"state" -> state
)
)
.applyOn { obj =>
config.connection match {
case Auth0PasswordlessConnectionKind.Email =>
val email =
params.select("username").asOpt[String].orElse(params.select("email").asOpt[String]).getOrElse("")
obj ++ Json.obj("email" -> email)
case Auth0PasswordlessConnectionKind.Sms =>
val phoneNumber =
params.select("username").asOpt[String].orElse(params.select("phone_number").asOpt[String]).getOrElse("")
obj ++ Json.obj("phone_number" -> phoneNumber)
}
}
env.Ws
.url(s"https://${domain}/passwordless/start")
.withRequestTimeout(10.seconds)
.post(payload)
.map { response =>
if (response.status == 200) {
val username = params
.select("username")
.asOpt[String]
.orElse(params.select("email").asOpt[String])
.orElse(params.select("phone_number").asOpt[String])
.getOrElse("")
BackendCallResponse(
NgPluginHttpResponse.fromResult(
Results.Ok(
Json.obj(
"auth0_response" -> response.json,
"state" -> state,
"username" -> username
)
)
),
None
).right
} else {
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.Status(response.status).apply(response.json)),
None
).right
}
}
}
override def callBackend(
ctx: NgbBackendCallContext,
delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
)(implicit
env: Env,
ec: ExecutionContext,
mat: Materializer
): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
val config =
ctx.cachedConfig(internalName)(Auth0PasswordlessAuthConfig.format).getOrElse(Auth0PasswordlessAuthConfig.default)
env.proxyState.authModule(config.ref) match {
case Some(authModule) if authModule.isInstanceOf[GenericOauth2ModuleConfig] => {
val oauthConfig = authModule.asInstanceOf[GenericOauth2ModuleConfig]
if (
ctx.request.method == "POST" && ctx.request.hasBody && ctx.request.contentType.contains("application/json")
) {
ctx.request.body.runFold(ByteString.empty)(_ ++ _).flatMap { bodyRaw =>
doStartFlow(ctx, Json.parse(bodyRaw.utf8String).asObject, config, oauthConfig)
}
} else {
doStartFlow(ctx, JsObject(ctx.request.queryParams.mapValues(_.json)), config, oauthConfig)
}
}
case None =>
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.Unauthorized(Json.obj("error" -> "unauthorized"))),
None
).rightf
}
}
}
class Auth0PasswordlessEndFlowEndpoint extends NgBackendCall {
override def steps: Seq[NgStep] = Seq(NgStep.CallBackend)
override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand
override def multiInstance: Boolean = true
override def core: Boolean = true
override def name: String = "Auth0 Passwordless end flow endpoint"
override def description: Option[String] = "This plugin provide an endpoint to end a passwordless flow".some
override def defaultConfigObject: Option[NgPluginConfig] = Some(Auth0PasswordlessAuthConfig.default)
override def useDelegates: Boolean = false
override def noJsForm: Boolean = true
override def configFlow: Seq[String] = Auth0PasswordlessAuthConfig.configFlow
override def configSchema: Option[JsObject] = Auth0PasswordlessAuthConfig.configSchema
private val logger = Logger("otoroshi-plugin-auth0-passwordless")
def doEndFlow(
ctx: NgbBackendCallContext,
params: JsObject,
config: Auth0PasswordlessAuthConfig,
oauthConfig: GenericOauth2ModuleConfig
)(implicit
env: Env,
ec: ExecutionContext,
mat: Materializer
): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
val username = params.select("username").asOpt[String].getOrElse("")
val code = params.select("code").asOpt[String].getOrElse("")
val payload = Json
.obj(
"grant_type" -> "http://auth0.com/oauth/grant-type/passwordless/otp",
"client_id" -> oauthConfig.clientId,
"client_secret" -> oauthConfig.clientSecret,
"username" -> username,
"otp" -> code,
"realm" -> config.connection.name,
"audience" -> config.audience,
"scope" -> oauthConfig.scope
)
.applyOnWithOpt(config.audience) { case (obj, audience) =>
obj ++ Json.obj("audience" -> audience)
}
env.Ws
.url(oauthConfig.tokenUrl)
.withRequestTimeout(10.seconds)
.post(payload)
.flatMap { response =>
if (response.status == 200) {
val token = response.json
val accessToken = token.select("access_token").asString
val module = GenericOauth2Module(oauthConfig)
module.getUserInfo(accessToken, env.datastores.globalConfigDataStore.latest()).flatMap { profile =>
val name = profile.select(oauthConfig.nameField).asOpt[String].getOrElse(username)
val email = profile.select(oauthConfig.emailField).asOpt[String].getOrElse(username)
val meta: Option[JsObject] = PrivateAppsUser
.select(profile, oauthConfig.otoroshiDataField)
.asOpt[String]
.map(s => Json.parse(s))
.orElse(
Option(PrivateAppsUser.select(profile, oauthConfig.otoroshiDataField))
)
.map(_.asOpt[JsObject].getOrElse(Json.obj()))
PrivateAppsUser(
randomId = IdGenerator.token(64),
name = name,
email = email,
profile = profile,
realm = oauthConfig.cookieSuffix(ctx.route.legacy),
token = token,
authConfigId = oauthConfig.id,
otoroshiData = oauthConfig.dataOverride
.get(email)
.map(v => oauthConfig.extraMetadata.deepMerge(v))
.orElse(Some(oauthConfig.extraMetadata.deepMerge(meta.getOrElse(Json.obj())))),
tags = oauthConfig.theTags,
metadata = oauthConfig.metadata,
location = oauthConfig.location
)
.validate(
ctx.route.legacy,
isRoute = true,
oauthConfig
) flatMap {
case Left(err) =>
logger.error(
s"login remote validation failed: ${err.display} - ${err.internal.map(_.stringify).getOrElse("")}"
)
BackendCallResponse(
NgPluginHttpResponse.fromResult(
Results.Unauthorized(
Json.obj(
"error" -> "unauthorized",
"error_description" -> err.display
)
)
),
None
).rightf
case Right(user) => {
user
.save(Duration(oauthConfig.sessionMaxAge, TimeUnit.SECONDS))
.map { _ =>
val host = ctx.rawRequest.domain
val cookies =
env.createPrivateSessionCookies(host, user.randomId, ctx.route.legacy, oauthConfig, user.some)
val sessionId = cookies.head.value
val cookieName = "oto-papps-" + oauthConfig.cookieSuffix(ctx.route.legacy)
BackendCallResponse(
NgPluginHttpResponse.fromResult(
Results
.Ok(
Json.obj(
"user" -> profile,
"session_id_cookie_name" -> cookieName,
"session_id" -> sessionId // can be passed as cookie value, or "Otoroshi-Token" header, or "pappsToken" query params
)
)
.withCookies(cookies: _*)
),
None
).right
}
}
}
}
} else {
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.Status(response.status).apply(response.json)),
None
).rightf
}
}
}
override def callBackend(
ctx: NgbBackendCallContext,
delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
)(implicit
env: Env,
ec: ExecutionContext,
mat: Materializer
): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
val config =
ctx.cachedConfig(internalName)(Auth0PasswordlessAuthConfig.format).getOrElse(Auth0PasswordlessAuthConfig.default)
env.proxyState.authModule(config.ref) match {
case Some(authModule) if authModule.isInstanceOf[GenericOauth2ModuleConfig] => {
val oauthConfig = authModule.asInstanceOf[GenericOauth2ModuleConfig]
if (
ctx.request.method == "POST" && ctx.request.hasBody && ctx.request.contentType.contains("application/json")
) {
ctx.request.body.runFold(ByteString.empty)(_ ++ _).flatMap { bodyRaw =>
doEndFlow(ctx, Json.parse(bodyRaw.utf8String).asObject, config, oauthConfig)
}
} else {
doEndFlow(ctx, JsObject(ctx.request.queryParams.mapValues(_.json)), config, oauthConfig)
}
}
case None =>
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.Unauthorized(Json.obj("error" -> "unauthorized"))),
None
).rightf
}
}
}
class Auth0PasswordlessStartEndFlowEndpoints extends NgBackendCall {
override def steps: Seq[NgStep] = Seq(NgStep.CallBackend)
override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand
override def multiInstance: Boolean = true
override def core: Boolean = true
override def name: String = "Auth0 Passwordless start/end flow endpoints"
override def description: Option[String] = "This plugin provide endpoints to start and end a passwordless flow".some
override def defaultConfigObject: Option[NgPluginConfig] = Some(Auth0PasswordlessAuthConfig.default)
override def useDelegates: Boolean = false
override def noJsForm: Boolean = true
override def configFlow: Seq[String] = Auth0PasswordlessAuthConfig.configFlow
override def configSchema: Option[JsObject] = Auth0PasswordlessAuthConfig.configSchema
override def callBackend(
ctx: NgbBackendCallContext,
delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
)(implicit
env: Env,
ec: ExecutionContext,
mat: Materializer
): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
if (ctx.request.path.endsWith("/start")) {
env.scriptManager.getAnyScript[Auth0PasswordlessStartFlowEndpoint](
s"cp:${classOf[Auth0PasswordlessStartFlowEndpoint].getName}"
) match {
case Left(err) =>
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.InternalServerError(Json.obj("error" -> "plugin not found"))),
None
).rightf
case Right(plugin) => plugin.callBackend(ctx, delegates)
}
} else if (ctx.request.path.endsWith("/end")) {
env.scriptManager.getAnyScript[Auth0PasswordlessEndFlowEndpoint](
s"cp:${classOf[Auth0PasswordlessEndFlowEndpoint].getName}"
) match {
case Left(err) =>
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.InternalServerError(Json.obj("error" -> "plugin not found"))),
None
).rightf
case Right(plugin) => plugin.callBackend(ctx, delegates)
}
} else {
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.NotFound(Json.obj("error" -> "not found"))),
None
).rightf
}
}
}
class Auth0PasswordlessFlow extends NgBackendCall with NgAccessValidator {
override def steps: Seq[NgStep] = Seq(NgStep.ValidateAccess, NgStep.CallBackend)
override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Authentication)
override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand
override def multiInstance: Boolean = true
override def core: Boolean = true
override def name: String = "Auth0 Passwordless start/end flow"
override def description: Option[String] = "This plugin provide endpoints to start and end a passwordless flow".some
override def defaultConfigObject: Option[NgPluginConfig] = Some(Auth0PasswordlessAuthConfig.default)
override def useDelegates: Boolean = true
override def noJsForm: Boolean = true
override def configFlow: Seq[String] = Auth0PasswordlessAuthConfig.configFlow
override def configSchema: Option[JsObject] = Auth0PasswordlessAuthConfig.configSchema
override def access(ctx: NgAccessContext)(implicit env: Env, ec: ExecutionContext): Future[NgAccess] = {
ctx.user match {
case Some(_) => NgAccess.NgAllowed.vfuture
case None => {
val config = ctx
.cachedConfig(internalName)(Auth0PasswordlessAuthConfig.format)
.getOrElse(Auth0PasswordlessAuthConfig.default)
env.proxyState.authModule(config.ref) match {
case None =>
Errors
.craftResponseResult(
"Auth. config. not found on the descriptor",
Results.InternalServerError,
ctx.request,
None,
"errors.auth.config.not.found".some,
attrs = ctx.attrs,
maybeRoute = ctx.route.some
)
.map(NgAccess.NgDenied.apply)
case Some(auth) => {
// here there is a datastore access (by key) to get the user session
PrivateAppsUserHelper.isPrivateAppsSessionValidWithAuth(ctx.request, ctx.route.legacy, auth).flatMap {
case Some(paUsr) =>
ctx.attrs.put(otoroshi.plugins.Keys.UserKey -> paUsr)
NgAccess.NgAllowed.vfuture
case None => {
NgAccess.NgDenied(Results.Unauthorized(otoroshi.views.html.privateapps.passwordless(env))).vfuture
}
}
}
}
}
}
}
override def callBackend(
ctx: NgbBackendCallContext,
delegates: () => Future[Either[NgProxyEngineError, BackendCallResponse]]
)(implicit
env: Env,
ec: ExecutionContext,
mat: Materializer
): Future[Either[NgProxyEngineError, BackendCallResponse]] = {
if (ctx.request.method == "POST" && ctx.request.path.endsWith("/passwordless/start")) {
env.scriptManager.getAnyScript[Auth0PasswordlessStartFlowEndpoint](
s"cp:${classOf[Auth0PasswordlessStartFlowEndpoint].getName}"
) match {
case Left(err) =>
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.InternalServerError(Json.obj("error" -> "plugin not found"))),
None
).rightf
case Right(plugin) => plugin.callBackend(ctx, delegates)
}
} else if (ctx.request.method == "POST" && ctx.request.path.endsWith("/passwordless/end")) {
env.scriptManager.getAnyScript[Auth0PasswordlessEndFlowEndpoint](
s"cp:${classOf[Auth0PasswordlessEndFlowEndpoint].getName}"
) match {
case Left(err) =>
BackendCallResponse(
NgPluginHttpResponse.fromResult(Results.InternalServerError(Json.obj("error" -> "plugin not found"))),
None
).rightf
case Right(plugin) => plugin.callBackend(ctx, delegates)
}
} else {
delegates()
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy