packages feed

oauth2-server-0.3.0.0: src/Web/OAuth2/AuthorizeCallbackAPI.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}

-- |
-- Module:      Web.OAuth2.AuthorizeCallbackAPI
-- Copyright:   (c) DPella AB 2025
-- License:     MPL-2.0
-- Maintainer:  <matti@dpella.io>, <lobo@dpella.io>
--
-- OAuth 2.1 Authorization Callback Handler.
--
-- This module handles the POST callback from the authorization login form.
-- It validates user credentials, generates authorization codes, and redirects
-- the user back to the client application with the authorization code.
--
-- The module implements PKCE (Proof Key for Code Exchange) support as defined
-- in RFC 7636 to protect against authorization code interception attacks.
module Web.OAuth2.AuthorizeCallbackAPI where

import Control.Concurrent.MVar
import Control.Monad (unless)
import Control.Monad.IO.Class (liftIO)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Clock
import GHC.Generics
import Network.URI (escapeURIString, isUnescapedInURIComponent)
import Web.OAuth2.Types
import Servant
import Servant.Auth.Server (AuthResult (..))
import Servant.HTML.Blaze
import Text.Blaze.Html5 (Html)
import Web.FormUrlEncoded (FromForm (..), parseMaybe, parseUnique)
import Web.OAuth2.AuthorizeAPI (validateScope)

-- | Form data submitted from the OAuth login page.
--
-- This data structure captures all the form fields from the login form,
-- including the original OAuth parameters that need to be preserved
-- throughout the authentication flow.
data LoginForm = LoginForm
  { username :: Text
  -- ^ User's login username
  , password :: Text
  -- ^ User's login password
  , login_client_id :: Text
  -- ^ OAuth client identifier
  , login_redirect_uri :: Text
  -- ^ Client's redirect URI
  , login_scope :: Text
  -- ^ Requested OAuth scopes
  , login_state :: Maybe Text
  -- ^ Client's state parameter (optional)
  , login_code_challenge :: Maybe Text
  -- ^ PKCE code challenge
  , login_code_challenge_method :: Maybe Text
  -- ^ PKCE challenge method (S256 or plain)
  }
  deriving (Eq, Show, Generic)

instance FromForm LoginForm where
  fromForm f =
    LoginForm
      <$> parseUnique "username" f
      <*> parseUnique "password" f
      <*> parseUnique "client_id" f
      <*> parseUnique "redirect_uri" f
      <*> parseUnique "scope" f
      <*> parseMaybe "state" f
      <*> parseMaybe "code_challenge" f
      <*> parseMaybe "code_challenge_method" f

-- | Servant API type for the authorization callback endpoint.
--
-- This endpoint receives the login form submission and processes
-- the authentication. It returns HTML that redirects the user
-- back to the client application.
type AuthorizeCallbackAPI =
  "authorize"
    :> "callback"
    :> ReqBody '[FormUrlEncoded] LoginForm
    :> Post '[HTML] Html

-- | Handle the authorization callback after user submits login credentials.
--
-- This function:
--
-- 1. Validates the user's credentials against the configured authentication backend
-- 2. If successful, generates a new authorization code with 10-minute expiry
-- 3. Stores the authorization code with associated PKCE parameters
-- 4. Redirects the user back to the client with the authorization code
-- 5. If authentication fails, redirects back to the login form with an error
--
-- Responses use HTTP redirects (303 See Other) with appropriate Location headers.
handleAuthorizeCallback
  :: (FormAuth usr, HasContextEntry ctxt (FormAuthSettings usr))
  => MVar (OAuthState usr)
  -> Context ctxt
  -> LoginForm
  -> Handler Html
handleAuthorizeCallback state_var ctxt LoginForm{..} = do
  auth_user <- liftIO $ runFormAuth ctxt username password
  case auth_user of
    Authenticated user -> do
      oauth_state <- liftIO $ readMVar state_var
      registeredClient <-
        case Map.lookup login_client_id (registered_clients oauth_state) of
          Nothing -> unauthorizedClient
          Just rc -> pure rc
      unless (login_redirect_uri `elem` registered_client_redirect_uris registeredClient) $
        invalidRequest "unauthorized_client" "Invalid redirect URI for client"
      unless (validateScope login_scope (registered_client_scope registeredClient)) $
        invalidRequest "invalid_scope" "Requested scope is not allowed for this client"
      case login_code_challenge of
        Nothing -> invalidRequest "invalid_request" "PKCE code_challenge required"
        Just _ -> pure ()
      let methodValid =
            maybe True (`elem` ["plain", "S256"]) login_code_challenge_method
      unless methodValid $
        invalidRequest "invalid_request" "Unsupported code_challenge_method"
      auth_code <- liftIO generateToken
      current_time <- liftIO getCurrentTime
      let expiry = addUTCTime 600 current_time
          new_auth_code =
            AuthCode
              auth_code
              login_client_id
              user
              login_redirect_uri
              login_scope
              expiry
              login_code_challenge
              login_code_challenge_method

      liftIO $ modifyMVar_ state_var $ \s ->
        return s{auth_codes = Map.insert auth_code new_auth_code (auth_codes s)}

      let redirect_url =
            buildRedirectUrl
              login_redirect_uri
              (("code", auth_code) : maybeParam "state" login_state)
      redirect303 redirect_url
    _ -> do
      let base = "../authorize"
          params =
            [ ("response_type", "code")
            , ("client_id", login_client_id)
            , ("redirect_uri", login_redirect_uri)
            , ("scope", login_scope)
            ]
              <> maybeParam "code_challenge" login_code_challenge
              <> maybeParam "code_challenge_method" login_code_challenge_method
              <> maybeParam "state" login_state
              <> [("error", "invalid_password")]
      redirect303 (buildRedirectUrl base params)
  where
    redirect303 :: Text -> Handler Html
    redirect303 location =
      throwError err303{errHeaders = [("Location", TE.encodeUtf8 location)]}

    maybeParam :: Text -> Maybe Text -> [(Text, Text)]
    maybeParam key = maybe [] (\value -> [(key, value)])

    buildRedirectUrl :: Text -> [(Text, Text)] -> Text
    buildRedirectUrl baseUri params =
      let (baseWithoutFragment, fragmentPart) = T.breakOn "#" baseUri
          fragmentSuffix =
            if T.null fragmentPart
              then ""
              else T.cons '#' (T.drop 1 fragmentPart)
          encodedParams =
            T.intercalate "&" $ fmap encodeParam params
          baseWithQuery
            | T.null encodedParams = baseWithoutFragment
            | "?" `T.isInfixOf` baseWithoutFragment = baseWithoutFragment <> "&" <> encodedParams
            | otherwise = baseWithoutFragment <> "?" <> encodedParams
      in  baseWithQuery <> fragmentSuffix

    encodeParam :: (Text, Text) -> Text
    encodeParam (key, value) =
      let pctEncode = T.pack . escapeURIString isUnescapedInURIComponent . T.unpack
      in  pctEncode key <> "=" <> pctEncode value

    invalidRequest :: Text -> Text -> Handler a
    invalidRequest errorCode errorDescription =
      throwError $ oauthErrorResponse err400 errorCode (Just errorDescription)

    unauthorizedClient :: Handler a
    unauthorizedClient =
      throwError $ oauthErrorResponse err401 "unauthorized_client" (Just "Client not registered or invalid client_id")