Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Command Line Client Support #1

Merged
merged 4 commits into from
Dec 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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