servant-auth icon indicating copy to clipboard operation
servant-auth copied to clipboard

Authorization versus Authentication

Open jkachmar opened this issue 6 years ago • 11 comments

This intentionally overlaps/duplicates #5, since that issue is relatively old and servant-auth, as well as Servant itself, has changed substantially since then.

Right now it's looking like servant-auth is going to become the blessed form of authentication handling in Servant, and one of the areas that's really lacking right now is how to handle route authorization through a custom combinator.

I don't really have a great solution in my mind, except that I'd like to have a combinator - or documented process of writing some combinator - that accepts the result of servant-auth's authentication and can then authorize access to a route based on the information there.

It'd be particularly useful if there was a documented method to use such a combinator inside a custom application transformer stack, as my main motivating example would be decoding the authentication token and using a DB connection in some ReaderT stack to check the permissions for that user's ID.

jkachmar avatar Dec 06 '17 17:12 jkachmar

This comment is in no way an official answer, it's just my perspective on how to add authorization in servant applications.

In my opinion (might be controversial? I don't know), the API type should not contain anything about authorization. Why? Well, I think that the API type should only contain "public information" about the API, more specifically all the information a client needs to send well-formed requests to that API. However, in my opinion the client should not have to know what the different authorization levels are and which ones are needed for which route etc. All the client should do is send some credentials, and then the server will perform some check on those and allow or deny access to a given route.

If we follow this perspective, then the most straighforward way to add authorization to your server would be to have some helper function userAtLeast :: User -> AuthorizationLevel -> Handler () which would check your user's authorization level and compare it against the required one. If sufficient, you just return (), and if not, you throwError .... You could then use it like:

someHandler user = do
  userAtLeast user Administrator
  -- what follows is not executed if the user is not an admin
  ...

I've used this approach, it's dead simple and can be as flexible as you want it to be.

... But I also did try once an approach where you would state that you require 1/ an authenticated user 2/ with at least some given role/authorization level. So in servant-auth it would be something like:

data Role = NormalUser | Moderator | Administrator
data User (minRole :: Role) = User { ... }

-- let's say we're using JWT
type API = Auth '[JWT] (User Administrator) :> Get '[JSON] Int

and then the auth check would only go through if the user is confirmed to be an admin. The main downside is that you have to write the authorization logic in the "auth check". Another one is that this type level param to User can be annoying/awkward to work with.

Finally, to my knowledge it's a bit hard to implement a solution like the one you describe, with our existing authentication combinator and a separate one for authorization that would reuse data from the authentication one. I mean that in the technical sense. Since each combinator is defined individually by its effect on an API type, it's hard to state that you want something particular to happen when both occur in the description of some route or API. If anyone ever gets something like that to work, I think I would insist a lot less on my conceptual argument against having authorization data in API descriptions. =P

Let me know what you think and if you have questions about how this all fits with custom monads for handlers and database connections and what not.

alpmestan avatar Dec 06 '17 18:12 alpmestan

That all sounds excellent, thanks so much for the thorough response!

I'll probably go with the straightforward example you've provided since it's really all I need at the moment, but it's nice to know that there are some other options easily accessible.

I'm fine with closing this issue then; would it also make sense to close #5?

jkachmar avatar Dec 06 '17 21:12 jkachmar

Well, it depends on whether people think my two suggestions are acceptable solutions, at least until someone comes up with a better one. In my opinion the "authorization problem" is indeed solved in servant (but the solution is under-documented). If "most people" agree, we can definitely close those issues (and just make sure we document the solutions in things like the WIP cookbook effort).

alpmestan avatar Dec 07 '17 10:12 alpmestan

https://ocharles.org.uk/blog/posts/2019-08-09-who-authorized-these-ghosts.html

domenkozar avatar Sep 24 '19 12:09 domenkozar

I'd like resurrect the ideas discussed by @m-renaud and @jkarni in #56. Does anyone else find these ideas worth pursuing still or is using a simple function in the handler as described by @alpmestan a more accepted solution?

As for the issue described in this comment, I believe this would be out of the scope of this library. Because as far as I'm aware most built-in authorization libraries don't deal with this case, such as the one that ships with asp.net.

Disco-Dave avatar Dec 01 '19 01:12 Disco-Dave

If you find an angle that has a good power-to-weight ratio and works around the technical issues that we have mentioned (like finding a way to "teleport" information from the authentication combinator to the authorization one, if you decide to go for two different combinators), I'm pretty confident that you'd make many haskellers happy by giving them a way to kill more boilerplate.

alpmestan avatar Dec 09 '19 11:12 alpmestan

I would like to play around this and see what happens. I believe they were discussing adding an extra field to the Delayed record. This is probably will I will start. Do you see any technical limitation in what was proposed in this comment? https://github.com/haskell-servant/servant-auth/issues/56#issuecomment-327544901

Disco-Dave avatar Dec 18 '19 23:12 Disco-Dave

Ah, I had forgotten about Julian's idea. I don't see a limitation off the top of my head, I'm afraid you'll have to give it a shot to know for sure that it works. :-)

alpmestan avatar Dec 19 '19 06:12 alpmestan

The other suggestion that was made was about adding the user to the context. Isn't this impossible since we get AuthResult user from within the DelayedIO?

instance ( n ~ 'S ('S 'Z)
         , HasServer (AddSetCookiesApi n api) (AuthResult v ': ctxs), AreAuths auths ctxs v
         , HasServer api ctxs -- this constraint is needed to implement hoistServer
         , AddSetCookies n (ServerT api Handler) (ServerT (AddSetCookiesApi n api) Handler)
         , ToJWT v
         , HasContextEntry ctxs CookieSettings
         , HasContextEntry ctxs JWTSettings
         ) => HasServer (Auth auths v :> api) ctxs where
  type ServerT (Auth auths v :> api) m = AuthResult v -> ServerT api m

#if MIN_VERSION_servant_server(0,12,0)
  hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt . s
#endif

  route _ context subserver =
    route (Proxy :: Proxy (AddSetCookiesApi n api))
          (<How would I get the AuthResult v into here?> :. context)
          (fmap go subserver `addAuthCheck` authCheck)

    where
      authCheck :: DelayedIO (AuthResult v, SetCookieList ('S ('S 'Z)))
      authCheck = withRequest $ \req -> liftIO $ do
        authResult <- runAuthCheck (runAuths (Proxy :: Proxy auths) context) req
        cookies <- makeCookies authResult
        return (authResult, cookies)

      jwtSettings :: JWTSettings
      jwtSettings = getContextEntry context

      cookieSettings :: CookieSettings
      cookieSettings = getContextEntry context

      makeCookies :: AuthResult v -> IO (SetCookieList ('S ('S 'Z)))
      makeCookies authResult = do
        xsrf <- makeXsrfCookie cookieSettings
        fmap (Just xsrf `SetCookieCons`) $
          case authResult of
            (Authenticated v) -> do
              ejwt <- makeSessionCookie cookieSettings jwtSettings v
              case ejwt of
                Nothing  -> return $ Nothing `SetCookieCons` SetCookieNil
                Just jwt -> return $ Just jwt `SetCookieCons` SetCookieNil
            _ -> return $ Nothing `SetCookieCons` SetCookieNil

      go :: (AuthResult v -> ServerT api Handler)
         -> (AuthResult v, SetCookieList n)
         -> ServerT (AddSetCookiesApi n api) Handler
      go fn (authResult, cookies) = addSetCookies cookies $ fn authResult

Disco-Dave avatar Jan 01 '20 18:01 Disco-Dave

I'm also confused how I could implement an addAuthorizeCheck for the given definition of delayed.

data Delayed env c where
  Delayed :: { capturesD :: env -> DelayedIO captures
             , methodD   :: DelayedIO ()
             , authD     :: DelayedIO auth
             , authorizeD :: auth -> DelayedIO auth'
             , acceptD   :: DelayedIO ()
             , contentD  :: DelayedIO contentType
             , paramsD   :: DelayedIO params
             , headersD  :: DelayedIO headers
             , bodyD     :: contentType -> DelayedIO body
             , serverD   :: captures
                         -> params
                         -> headers
                         -> auth'
                         -> body
                         -> Request
                         -> RouteResult c
             } -> Delayed env c

I think they only way I'd be able to set authorizeD to something else would be if I also set authD at the same time. Like the following:

addAuthorizeCheck :: Delayed env (a -> b)
                  -> DelayedIO auth
                  -> (auth -> DelayedIO a)
                  -> Delayed env b
addAuthorizeCheck Delayed{..} newAuthD newAuthorizeD =
  Delayed
    { authD = (,) <$> authD <*> newAuthD
    , authorizeD = \(auth, a) -> (,) <$> authorizeD auth <*> newAuthorizeD a
    , serverD = \c p h (y, v) b req -> ($ v) <$> serverD c p h y b req
    , ..
    } -- Note [Existential Record Update]

However this doesn't help us because we have to send it a DelayedIO auth and if we are doing that then we could of just included the authorization logic in with the Delayed auth that is passed in with addAuthCheck.

I'm hoping that I'm wrong though, and someone can point me into the correct direction instead.

Disco-Dave avatar Jan 01 '20 22:01 Disco-Dave

I really enjoyed https://serokell.io/blog/haskell-type-level-witness and while it uses some type-level trickery, each concept is simple.

domenkozar avatar Aug 18 '20 08:08 domenkozar