diff --git a/shotgun/.bsp/sbt.json b/shotgun/.bsp/sbt.json
index b7bca3d..c5b0993 100644
--- a/shotgun/.bsp/sbt.json
+++ b/shotgun/.bsp/sbt.json
@@ -1 +1 @@
-{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.10%252B7/OpenJDK17U-jdk_x64_mac_hotspot_17.0.10_7.tar.gz/jdk-17.0.10+7/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Caches/Coursier/arc/https/github.com/sbt/sbt/releases/download/v1.8.2/sbt-1.8.2.zip/sbt/bin/sbt","xsbt.boot.Boot","-bsp"]}
\ No newline at end of file
+{"name":"sbt","version":"1.9.1","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/Users/willfreeman/Library/Java/JavaVirtualMachines/corretto-11.0.19/Contents/Home/bin/java","-Xms100m","-Xmx100m","-classpath","/Users/willfreeman/Library/Application Support/JetBrains/IdeaIC2024.1/plugins/Scala/launcher/sbt-launch.jar","-Dsbt.script=/Users/willfreeman/Library/Application%20Support/Coursier/bin/sbt","xsbt.boot.Boot","-bsp"]}
\ No newline at end of file
diff --git a/shotgun/build.sbt b/shotgun/build.sbt
index dbe9dda..dc121fb 100644
--- a/shotgun/build.sbt
+++ b/shotgun/build.sbt
@@ -14,6 +14,10 @@ lazy val root = (project in file("."))
val PekkoVersion = "1.0.3"
val PekkoHttpVersion = "1.0.1"
libraryDependencies ++= Seq(
+ "ch.qos.logback" % "logback-classic" % "1.5.6",
+ "org.slf4j" % "slf4j-api" % "2.0.12",
+ "com.auth0" % "jwks-rsa" % "0.22.1",
+ "com.github.jwt-scala" % "jwt-json-common_native0.4_2.12" % "10.0.1",
"org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion,
"org.apache.pekko" %% "pekko-stream" % PekkoVersion,
"org.apache.pekko" %% "pekko-http" % PekkoHttpVersion,
diff --git a/shotgun/src/main/resources/logback.xml b/shotgun/src/main/resources/logback.xml
new file mode 100644
index 0000000..b27731b
--- /dev/null
+++ b/shotgun/src/main/resources/logback.xml
@@ -0,0 +1,14 @@
+
+
Code: " + code.getOrElse("None") + "
")) + path("delete-object") { + hasPermissions(List("photo:delete")) { claim => + post { + entity(as[DeleteObjectRequest]) { request => + val res = awsClient.deleteObject("deflock-photo-uploads", request.objectKey) + complete(res) + } } } } diff --git a/shotgun/src/main/scala/services/JwtAuthenticator.scala b/shotgun/src/main/scala/services/JwtAuthenticator.scala new file mode 100644 index 0000000..9bc16fd --- /dev/null +++ b/shotgun/src/main/scala/services/JwtAuthenticator.scala @@ -0,0 +1,88 @@ +package services + +import org.apache.pekko.http.scaladsl.server.{Directive1, Directives} +import pdi.jwt.{Jwt, JwtAlgorithm, JwtOptions} +import spray.json.JsValue +import spray.json.JsonParser +import com.auth0.jwk.{JwkProvider, JwkProviderBuilder} + +import java.security.PublicKey +import java.security.interfaces.RSAPublicKey +import scala.util.{Failure, Success, Try} +import java.util.concurrent.TimeUnit +import org.slf4j.{Logger, LoggerFactory} + +import java.util.Base64 + +object JwtAuthenticator extends Directives { + val logger: Logger = LoggerFactory.getLogger(getClass) + val domain = "https://deflock.us.auth0.com" + val jwkProvider: JwkProvider = new JwkProviderBuilder(domain) + .cached(10, 24, TimeUnit.HOURS) // Cache up to 10 keys for 24 hours + .build() + + def getPublicKey(kid: String): Option[PublicKey] = { + Try { + val jwk = jwkProvider.get(kid) + jwk.getPublicKey.asInstanceOf[RSAPublicKey] + } match { + case Success(publicKey) => Some(publicKey) + case Failure(_) => None + } + } + + def validateToken(token: String): Option[JsValue] = { + val parts = token.split("\\.") + if (parts.length != 3) { + return None + } + + val rawHeader = new String(Base64.getUrlDecoder.decode(parts(0))) + val headerJson = JsonParser(rawHeader) + val kid = headerJson.asJsObject.fields.get("kid").flatMap { + case spray.json.JsString(k) => Some(k) + case _ => None + } + + kid.flatMap(getPublicKey) match { + case Some(publicKey) => + Jwt.decode(token, publicKey, Seq(JwtAlgorithm.RS256), JwtOptions.DEFAULT) match { + case Success(claim) => + Some(JsonParser(claim.content)) + case Failure(_) => + None + } + case None => + None + } + } + + def authenticated: Directive1[JsValue] = { + optionalHeaderValueByName("Authorization").flatMap { + case Some(header) if header.startsWith("Bearer ") => + val token = header.substring("Bearer ".length) + validateToken(token) match { + case Some(claim) => provide(claim) + case None => complete((401, "Invalid token")) + } + case _ => + complete((401, "Missing or malformed Authorization header")) + } + } + + def hasPermissions(requiredPermissions: List[String]): Directive1[JsValue] = { + authenticated.flatMap { claim => + val permissions = claim.asJsObject.fields.get("permissions").flatMap { + case spray.json.JsArray(elements) => Some(elements.collect { case spray.json.JsString(permission) => permission }) + case _ => None + } + + permissions match { + case Some(userPermissions) if requiredPermissions.forall(userPermissions.contains) => + provide(claim) + case _ => + complete((403, "Insufficient permissions")) + } + } + } +} diff --git a/webapp/src/main.ts b/webapp/src/main.ts index 35c07a5..909ac48 100644 --- a/webapp/src/main.ts +++ b/webapp/src/main.ts @@ -16,6 +16,7 @@ const auth0 = createAuth0({ clientId: "IEBa7ckgWrMGErTWXA8Z9q91hre7uII2", authorizationParams: { redirect_uri: window.location.origin, + audience: "https://deflock.me/api", }, cacheLocation: 'localstorage', })