Skip to content

Commit

Permalink
Merge pull request #1 from bblfish/CommandLine
Browse files Browse the repository at this point in the history
Command Line Client Support
  • Loading branch information
bblfish authored Dec 22, 2022
2 parents 5ce09dc + 992a6fe commit d6a1c45
Show file tree
Hide file tree
Showing 19 changed files with 1,183 additions and 488 deletions.
2 changes: 0 additions & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
root = true

[*]
indent_style = tab
indent_size = 3

7 changes: 6 additions & 1 deletion .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
version = "3.0.0-RC6"
version = 3.5.9
runner.dialect = scala3

maxColumn = 100

rewrite.rules = [Imports]
rewrite.imports.sort = scalastyle
67 changes: 67 additions & 0 deletions authn/shared/src/main/scala/net/bblfish/app/auth/AuthNClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2021 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package net.bblfish.app.auth

import cats.effect.kernel.{Ref, Sync}
import cats.effect.std.Hotswap
import cats.effect.{Async, Concurrent, Resource}
import cats.syntax.all.*
import net.bblfish.app.Wallet
import org.http4s.Uri.Host
import org.http4s.client.Client
import org.http4s.client.middleware.FollowRedirect.{
getRedirectUris,
methodForRedirect,
redirectUrisKey
}
import org.http4s.headers.*
import org.http4s.{BasicCredentials, Header, Request, Response, Status}

import scala.util.{Failure, Success, Try}

/** Client Authentication is a middleware that transforms a Client into a new Client that can use a
* Wallet to have requests signed.
*/
object AuthNClient:
def apply[F[_]: Concurrent](wallet: Wallet[F])(
client: Client[F]
): Client[F] =

def authLoop(
req: Request[F],
attempts: Int,
hotswap: Hotswap[F, Response[F]]
): F[Response[F]] =
hotswap.clear *> // Release the prior connection before allocating a new
hotswap.swap(client.run(req)).flatMap { (resp: Response[F]) =>
// todo: may want a lot more flexibility than attempt numbering to determine if we should retry or not.
resp.status match
case Status.Unauthorized if attempts < 1 =>
wallet.sign(resp, req).flatMap(newReq => authLoop(newReq, attempts + 1, hotswap))
case _ => resp.pure[F]
}

Client { req =>
// using the pattern from FollowRedirect example using Hotswap.
// Not 100% sure this is so much needed here...
Hotswap.create[F, Response[F]].flatMap { hotswap =>
Resource.eval(authLoop(req, 0, hotswap))
}
}
end apply

end AuthNClient
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* Copyright 2021 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package run.cosy.solid.app.auth

import cats.effect.*
import cats.effect.std.Semaphore
import cats.syntax.all.*
import net.bblfish.app.auth.AuthNClient
import org.http4s.Uri.Host
import org.http4s.client.Client
import org.http4s.client.dsl.Http4sClientDsl
import org.http4s.client.middleware.ResponseLogger
import org.http4s.dsl.io.{->, Ok, Unauthorized, *}
import org.http4s.headers.*
import org.http4s.server.middleware.authentication.BasicAuth
import org.http4s.server.{AuthMiddleware, Router}
import org.http4s.syntax.all.*
import org.http4s.{
AuthedRoutes,
BasicCredentials,
Challenge,
Headers,
HttpRoutes,
Request,
Response,
Status,
Uri
}
import org.typelevel.ci.*

import java.util.concurrent.atomic.*

case class User(id: Long, name: String)

class AuthNClientTest extends munit.CatsEffectSuite {

val realm = "Test Realm"
val username = "Test User"
val password = "Test Password"

def publicRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] { case GET -> Root =>
Ok("Hello World")
}

val authedRoutes: AuthedRoutes[String, IO] = AuthedRoutes.of[String, IO] {
case GET -> Root as user => Ok(user)
case req as _ => Response.notFoundFor(req)
}

def validatePassword(creds: BasicCredentials): IO[Option[String]] =
IO.pure {
if (creds.username == username && creds.password == password)
Some(creds.username)
else None
}

val basicAuthMiddleware: AuthMiddleware[IO, String] =
BasicAuth(realm, validatePassword)

//
// test server
//
{
val basicAuthedService = basicAuthMiddleware(authedRoutes)

def routes: HttpRoutes[IO] = Router[IO](
"/pub" -> publicRoutes,
"/auth" -> basicAuthedService
)

test("public Route needs no authentication") {
routes(Request[IO](uri = uri"/pub/")).map { (res: Response[IO]) =>
assertEquals(res.status, Status.Ok)
}
}

test(
"BasicAuthentication should respond to a request with unknown username with 401"
) {
val req = Request[IO](
uri = uri"/auth",
headers =
Headers(Authorization(BasicCredentials("Wrong User", password)))
)
routes(req).foldF(IO(fail("no route"))) { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.Unauthorized)) >>
IO(
assertEquals(
res.headers.get[`WWW-Authenticate`].map(_.value),
Some(
Challenge("Basic", realm, Map("charset" -> "UTF-8")).toString
)
)
)
}
}

test(
"BasicAuthentication should fail to respond to a request for non existent resource"
) {
val req = Request[IO](
uri = uri"/doesNotExist",
headers =
Headers(Authorization(BasicCredentials("Wrong User", password)))
)
routes(req).foldF(IO(assert(true, "route does not exist"))) {
(res: Response[IO]) =>
IO(fail("route does not exist"))
}
}

test(
"BasicAuthentication should respond to a request with correct credentials"
) {
val req = Request[IO](
uri = uri"/auth",
headers = Headers(Authorization(BasicCredentials(username, password)))
)
routes(req).foldF(IO(fail("no route"))) { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.Ok)) >>
res.as[String].map(s => assertEquals(s, username))
}
}

test(
"BasicAuthentication responds to authenticated non-existent resource with 404"
) {
val req = Request[IO](
uri = uri"/auth/nonExistent",
headers = Headers(Authorization(BasicCredentials(username, password)))
)
routes(req).foldF(IO(fail("no route"))) { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.NotFound))
}
}

// test with client now
val defaultClient: Client[IO] = Client.fromHttpApp(routes.orNotFound)
// val logedClient: Client[IO] = ResponseLogger[IO](true, true, logAction = Some(s => IO(println(s))))(defaultClient)
val client: Client[IO] = AuthNClient[IO](
AuthNClient.basicWallet(
Map(Uri.RegName("localhost") ->
new AuthNClient.BasicId(username, password)
)
)
)(defaultClient)

val clientBad: Client[IO] = AuthN[IO](
AuthNClient.basicWallet(
Map(
Uri.RegName("localhost") -> new AuthNClient.BasicId(
username,
password + "bad"
)
)
)
)(defaultClient)

test("Wallet Based Auth") {
client.get(uri"http://localhost/auth") { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.Ok)) >>
res.as[String].map(s => assertEquals(s, username))

}
}

test("Wallet Based Auth on Non Existent resource") {
client.get(uri"http://localhost/auth") { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.Ok)) >>
res.as[String].map(s => assertEquals(s, username))

}
}

test("Wallet Based Auth with bad password fails on protected resources") {
clientBad.get(uri"http://localhost/auth") { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.Unauthorized))
}
clientBad.get(uri"http://localhost/auth/NonExistent") {
(res: Response[IO]) =>
IO(assertEquals(res.status, Status.Unauthorized))
}
}

test("Wallet Based Auth with bad password succeeds on public resources") {
clientBad.get(uri"http://localhost/pub") { (res: Response[IO]) =>
IO(assertEquals(res.status, Status.Ok)) >>
res.as[String].map(s => assertEquals(s, "Hello World"))
}
}
}

}
53 changes: 0 additions & 53 deletions authn/src/main/scala/run/cosy/app/auth/AuthNClient.scala

This file was deleted.

Loading

0 comments on commit d6a1c45

Please sign in to comment.