oauth2-server is a small, composable OAuth 2.1 authorization server for Haskell/Servant. It implements the core endpoints for authorization code with PKCE, dynamic client registration, token issuance and refresh, and discovery metadata. It integrates with servant-auth-server to mint JWT access tokens and lets you plug in your own username/password authentication via a simple typeclass.
This library is designed to be embedded inside your existing Servant application, mounting the OAuth routes alongside your APIs.
- OAuth 2.1 authorization code flow with PKCE (RFC 6749 + RFC 7636)
- Token endpoint with refresh token rotation
- Dynamic client registration (RFC 7591)
- Authorization server metadata discovery (RFC 8414)
- JWT access tokens via
servant-auth-server - Pluggable user authentication through a
FormAuthtypeclass - In‑memory refresh token persistence by default, with an interface to plug your own store
GET /.well-known/oauth-authorization-server— discovery metadataGET /authorize— start authorization (renders a login form)POST /authorize/callback— handles login form submission and issues authorization codesPOST /token— exchanges codes for tokens and refreshes tokensPOST /register— dynamic client registration
PKCE is enforced for all clients using the authorization code grant. Use either S256 or plain as the challenge method.
Add the package to your library or executable stanza:
build-depends:
base
, servant
, servant-server
, servant-auth-server
, blaze-html
, text
, aeson
, containers
, oauth2-server -- this packageThis project targets GHC 9.12 (see cabal.project).
Below is a minimal Servant application that mounts the OAuth server. It defines a user type, a simple FormAuth instance, configures JWT settings, initializes the OAuth state, and serves the combined OAuth API.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
module Main where
import Control.Concurrent.MVar (newMVar)
import Data.Text (Text)
import GHC.Generics (Generic)
import Network.Wai (Application)
import Network.Wai.Handler.Warp (run)
import Servant
import Servant.Auth.Server
import Web.OAuth2 (OAuthAPI, oAuthAPI, defaultLoginFormRenderer)
import Web.OAuth2.Types
-- Your application user type and JWT instances
data User = User { userId :: Text } deriving (Show, Generic)
instance ToJWT User
instance FromJWT User
-- Plug in your authentication (username/password) logic
data MyAuthSettings = MyAuthSettings
instance FormAuth User where
type FormAuthSettings User = MyAuthSettings
runFormAuth _ "alice" "wonderland" = pure (Authenticated (User "alice"))
runFormAuth _ _ _ = pure NoSuchUser
type Ctx = '[JWTSettings, MyAuthSettings]
mkApp :: IO Application
mkApp = do
jwk <- generateKey
let jwt = defaultJWTSettings jwk
ctx = jwt :. MyAuthSettings :. EmptyContext
-- In-memory refresh-token persistence (swap for your DB if needed)
rtp <- mkDefaultRefreshTokenPersistence
st <- newMVar (initOAuthState @User "http://localhost" 8080 rtp defaultLoginFormRenderer)
pure $ serveWithContext (Proxy :: Proxy OAuthAPI) ctx (oAuthAPI st ctx)
main :: IO ()
main = mkApp >>= run 8080Notes:
initOAuthStatesets the base URL and port used in discovery metadata.- Pass
defaultLoginFormRendererfor the built-in login page, or supply your ownLoginFormParams -> Htmlfunction to customise the look-and-feel. - For production, provide a durable
RefreshTokenPersistence(e.g. database) by implementingpersistRefreshToken,deleteRefreshToken, andlookupRefreshToken. - The default login page references
/static/logo.pngif present; it's optional.
This walkthrough registers a client, runs a PKCE authorization code flow, exchanges the code for tokens, and refreshes the token. Replace placeholders as needed.
- Register a client
curl -s http://localhost:8080/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "My App",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"token_endpoint_auth_method": "none"
}'
# => { "client_id": "client_...", ... }- Start authorization (PKCE)
For a simple demo, use code_challenge_method=plain and set both challenge and verifier to the same value, e.g. testverifier.
Open in your browser:
http://localhost:8080/authorize?response_type=code&client_id=CLIENT_ID\
&redirect_uri=http://localhost:3000/callback&scope=read&state=xyz\
&code_challenge=testverifier&code_challenge_method=plain
Log in with the credentials handled by your FormAuth instance (from the example above: username alice, password wonderland). You will be redirected to the redirect_uri with ?code=...&state=xyz.
- Exchange the code for tokens
curl -s http://localhost:8080/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:3000/callback&client_id=CLIENT_ID&code_verifier=testverifier"
# => { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "scope": "read" }- Refresh the access token (rotation)
curl -s http://localhost:8080/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "grant_type=refresh_token&refresh_token=REFRESH_TOKEN&client_id=CLIENT_ID"
# => { "access_token": "...", "refresh_token": "..." }- Discover server metadata (optional)
curl -s http://localhost:8080/.well-known/oauth-authorization-server | jqOAuthAPI— combined Servant API for all OAuth endpointsoAuthAPI :: MVar (OAuthState usr) -> Context ctxt -> Server OAuthAPI— server implementationFormAuth usr— plug‑in credential verification for your user type:- associated type
FormAuthSettings usr runFormAuth :: Context ctxt -> Text -> Text -> IO (AuthResult usr)
- associated type
OAuthState usr— holds authorization codes, client registry, refresh‑token persistence, and login form renderermkDefaultRefreshTokenPersistence— in‑memoryRefreshTokenstorage (replace in production)initOAuthState— construct initial state with base URL, port, and login form rendererdefaultLoginFormRenderer— built-in login page; pass toinitOAuthStateor replace with your ownLoginFormParams— parameters available to a custom login form renderer
See also:
tests/OAuth/FlowSpec.hs— end‑to‑end flow covering registration, authorization, token exchange, and refreshtests/OAuth/TestUtils.hs— exampleFormAuthinstance and JWT configuration
Run tests:
cabal build
cabal test- Always serve over HTTPS in production. Set
oauth_urlaccordingly. - Use a strong JWT signing key and appropriate token lifetimes.
- PKCE is required for authorization code exchanges. Prefer
S256in real clients. - The default refresh‑token store is in‑memory and not durable; implement your own for production.
See LICENSE in this repository.