oauth2-server (empty) → 0.3.0.0
raw patch · 20 files changed
+4680/−0 lines, 20 filesdep +QuickCheckdep +aesondep +async
Dependencies added: QuickCheck, aeson, async, base, base64-bytestring, blaze-html, blaze-markup, bytestring, containers, cryptonite, http-api-data, http-client, http-types, memory, network-uri, oauth2-server, random, scientific, servant, servant-auth-server, servant-blaze, servant-server, tasty, tasty-hunit, tasty-quickcheck, text, time, transformers, wai, wai-extra, warp
Files
- CHANGELOG.md +55/−0
- LICENSE +373/−0
- README.md +200/−0
- oauth2-server.cabal +132/−0
- src/Web/OAuth2.hs +57/−0
- src/Web/OAuth2/AuthorizeAPI.hs +244/−0
- src/Web/OAuth2/AuthorizeCallbackAPI.hs +197/−0
- src/Web/OAuth2/Internal.hs +45/−0
- src/Web/OAuth2/MetadataAPI.hs +105/−0
- src/Web/OAuth2/RegisterAPI.hs +447/−0
- src/Web/OAuth2/TokenAPI.hs +383/−0
- src/Web/OAuth2/Types.hs +318/−0
- test/Main.hs +24/−0
- test/Web/OAuth2/AuthorizeCallbackSpec.hs +241/−0
- test/Web/OAuth2/AuthorizeSpec.hs +260/−0
- test/Web/OAuth2/FlowSpec.hs +275/−0
- test/Web/OAuth2/MetadataSpec.hs +69/−0
- test/Web/OAuth2/RegisterSpec.hs +487/−0
- test/Web/OAuth2/TestUtils.hs +228/−0
- test/Web/OAuth2/TokenSpec.hs +540/−0
+ CHANGELOG.md view
@@ -0,0 +1,55 @@+# Changelog++# 0.3.0.0+## Added+- Configurable login form: `initOAuthState` now accepts a `LoginFormParams -> Html` renderer, allowing library users to fully replace the built-in login page. Pass `defaultLoginFormRenderer` for the previous behaviour.++## Changed+- **Breaking:** `initOAuthState` takes an additional `(LoginFormParams -> Html)` argument.++# 0.2.1.0+## Added+- Dynamic client management endpoint at `/register/{client_id}` supporting GET/PUT/DELETE for registrations authenticated via the issued `registration_access_token`.++## Security+- Token endpoint responses now include `Cache-Control: no-store` and `Pragma: no-cache`, preventing intermediaries from caching bearer tokens.+- Authorization login form no longer fabricates an empty `state` value; parameters are only echoed when provided by the client, preserving CSRF protection for state-less callers.+- Dynamic client registration now enforces absolute redirect URIs (https-only except localhost loopback) and rejects fragment-bearing or empty lists, preventing misconfigurations that lead to insecure redirects.+- Replaced predictable `StdGen` token generator with Base64URL-encoded output sourced from `cryptonite`'s `getRandomBytes`, ensuring authorization codes, refresh tokens, and client secrets draw from strong entropy.+- Registration endpoint now returns `client_secret` (and expiry metadata) for confidential clients so freshly issued credentials can be retrieved immediately.+- Authorization callback revalidates registered clients, redirect URIs, scopes, and PKCE parameters before minting authorization codes, closing the tampering vector that allowed arbitrary redirect destinations and scope escalation.+- Authorization code redemption inside the token endpoint now executes under a single state lock, preventing concurrent exchanges from reusing the same code.+- Refresh token rotation is now atomic, stopping concurrent refresh requests from returning multiple valid tokens for the same handle.+## Fixed+- Authorization login and retry flows now generate relative callback URLs, so mounting the server under a sub-path (e.g. `/oauth`) no longer breaks form submissions or error redirects.+- Token endpoint error responses also emit `Cache-Control: no-store` and `Pragma: no-cache`, keeping sensitive failures out of intermediary caches.+- Dynamic client registration accepts any `127.0.0.0/8` loopback redirect host, restoring compatibility with tooling that binds to alternate loopback addresses.+- Dynamic client registration omits `client_secret` and `client_secret_expires_at` when no secret is issued, matching RFC 7591 expectations.+- Registration now rejects unsupported `token_endpoint_auth_method` values instead of provisioning unusable clients.+- Token endpoint `invalid_client` responses include a `WWW-Authenticate` challenge so OAuth clients can discover the required authentication scheme.+- Authorization endpoint now emits OAuth error responses via 303 redirects to the validated `redirect_uri`, including the original `state` when present, so clients receive spec-compliant failure notifications.+- Authorization callback now returns a relative `Location: ../authorize` on credential failures, guaranteeing the retry redirect lands on the authorize endpoint even when the server is mounted under a sub-path.+- Authorization callback now performs RFC-compliant 303 redirects with correctly constructed `Location` headers instead of relying on HTML meta refresh, preserving existing redirect URI queries and fragments.+- Discovery metadata now preserves the configured base URL, appending the OAuth server port only when absent and constructing endpoint paths without producing malformed `host:port:port` strings.+- Token issuance no longer crashes the server on JWT signing failures; such errors now surface as OAuth `server_error` responses (HTTP 500).+- Discovery metadata advertises all supported token endpoint authentication methods, including `client_secret_post`, preventing metadata-driven clients from failing their confidential flows.+- Dynamic client registration accepts the RFC 7591 `scope` field and persists it, rather than silently defaulting every client to `"read write"`.+- OAuth error responses now set the `application/json` content type, allowing clients to parse structured failures reliably.+- The authorize endpoint now returns RFC-compliant `invalid_request` or `unsupported_response_type` errors when callers omit or mis-state required parameters.+- Authorization code exchanges no longer mint refresh tokens for clients that omit the `refresh_token` grant, aligning runtime behaviour with registered capabilities.+- Confidential client registrations now leave `client_secret_expires_at` unset for non-expiring secrets instead of reporting an immediately expired timestamp.+- Dynamic client registration replies with HTTP 201 Created, returning `registration_access_token` and `registration_client_uri` alongside the client metadata so RFC 7591 clients can manage their registrations.+- Authorization server metadata now reports the union of scopes registered by clients, ensuring discovery reflects dynamically configured scope values.++## Changed+- Source module headers now declare the MPL-2.0 license to match the package manifest.+- Added `Web.OAuth2.Internal` to expose a stable-for-tests surface so the test suite can target internals without compiling the entire source tree.+- Restored the full suite of OAuth endpoint tests, including end-to-end flow coverage, after they were accidentally dropped during the namespace migration.++# 0.2.0.0+## Changed+- Renamed the exposed module hierarchy from `OAuth` to `Web.OAuth`.+- Updated package metadata and documentation to reflect the new namespace.++# 0.1.0.0+- Initial release.
+ LICENSE view
@@ -0,0 +1,373 @@+Mozilla Public License Version 2.0+==================================++1. Definitions+--------------++1.1. "Contributor"+ means each individual or legal entity that creates, contributes to+ the creation of, or owns Covered Software.++1.2. "Contributor Version"+ means the combination of the Contributions of others (if any) used+ by a Contributor and that particular Contributor's Contribution.++1.3. "Contribution"+ means Covered Software of a particular Contributor.++1.4. "Covered Software"+ means Source Code Form to which the initial Contributor has attached+ the notice in Exhibit A, the Executable Form of such Source Code+ Form, and Modifications of such Source Code Form, in each case+ including portions thereof.++1.5. "Incompatible With Secondary Licenses"+ means++ (a) that the initial Contributor has attached the notice described+ in Exhibit B to the Covered Software; or++ (b) that the Covered Software was made available under the terms of+ version 1.1 or earlier of the License, but not also under the+ terms of a Secondary License.++1.6. "Executable Form"+ means any form of the work other than Source Code Form.++1.7. "Larger Work"+ means a work that combines Covered Software with other material, in+ a separate file or files, that is not Covered Software.++1.8. "License"+ means this document.++1.9. "Licensable"+ means having the right to grant, to the maximum extent possible,+ whether at the time of the initial grant or subsequently, any and+ all of the rights conveyed by this License.++1.10. "Modifications"+ means any of the following:++ (a) any file in Source Code Form that results from an addition to,+ deletion from, or modification of the contents of Covered+ Software; or++ (b) any new file in Source Code Form that contains any Covered+ Software.++1.11. "Patent Claims" of a Contributor+ means any patent claim(s), including without limitation, method,+ process, and apparatus claims, in any patent Licensable by such+ Contributor that would be infringed, but for the grant of the+ License, by the making, using, selling, offering for sale, having+ made, import, or transfer of either its Contributions or its+ Contributor Version.++1.12. "Secondary License"+ means either the GNU General Public License, Version 2.0, the GNU+ Lesser General Public License, Version 2.1, the GNU Affero General+ Public License, Version 3.0, or any later versions of those+ licenses.++1.13. "Source Code Form"+ means the form of the work preferred for making modifications.++1.14. "You" (or "Your")+ means an individual or a legal entity exercising rights under this+ License. For legal entities, "You" includes any entity that+ controls, is controlled by, or is under common control with You. For+ purposes of this definition, "control" means (a) the power, direct+ or indirect, to cause the direction or management of such entity,+ whether by contract or otherwise, or (b) ownership of more than+ fifty percent (50%) of the outstanding shares or beneficial+ ownership of such entity.++2. License Grants and Conditions+--------------------------------++2.1. Grants++Each Contributor hereby grants You a world-wide, royalty-free,+non-exclusive license:++(a) under intellectual property rights (other than patent or trademark)+ Licensable by such Contributor to use, reproduce, make available,+ modify, display, perform, distribute, and otherwise exploit its+ Contributions, either on an unmodified basis, with Modifications, or+ as part of a Larger Work; and++(b) under Patent Claims of such Contributor to make, use, sell, offer+ for sale, have made, import, and otherwise transfer either its+ Contributions or its Contributor Version.++2.2. Effective Date++The licenses granted in Section 2.1 with respect to any Contribution+become effective for each Contribution on the date the Contributor first+distributes such Contribution.++2.3. Limitations on Grant Scope++The licenses granted in this Section 2 are the only rights granted under+this License. No additional rights or licenses will be implied from the+distribution or licensing of Covered Software under this License.+Notwithstanding Section 2.1(b) above, no patent license is granted by a+Contributor:++(a) for any code that a Contributor has removed from Covered Software;+ or++(b) for infringements caused by: (i) Your and any other third party's+ modifications of Covered Software, or (ii) the combination of its+ Contributions with other software (except as part of its Contributor+ Version); or++(c) under Patent Claims infringed by Covered Software in the absence of+ its Contributions.++This License does not grant any rights in the trademarks, service marks,+or logos of any Contributor (except as may be necessary to comply with+the notice requirements in Section 3.4).++2.4. Subsequent Licenses++No Contributor makes additional grants as a result of Your choice to+distribute the Covered Software under a subsequent version of this+License (see Section 10.2) or under the terms of a Secondary License (if+permitted under the terms of Section 3.3).++2.5. Representation++Each Contributor represents that the Contributor believes its+Contributions are its original creation(s) or it has sufficient rights+to grant the rights to its Contributions conveyed by this License.++2.6. Fair Use++This License is not intended to limit any rights You have under+applicable copyright doctrines of fair use, fair dealing, or other+equivalents.++2.7. Conditions++Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted+in Section 2.1.++3. Responsibilities+-------------------++3.1. Distribution of Source Form++All distribution of Covered Software in Source Code Form, including any+Modifications that You create or to which You contribute, must be under+the terms of this License. You must inform recipients that the Source+Code Form of the Covered Software is governed by the terms of this+License, and how they can obtain a copy of this License. You may not+attempt to alter or restrict the recipients' rights in the Source Code+Form.++3.2. Distribution of Executable Form++If You distribute Covered Software in Executable Form then:++(a) such Covered Software must also be made available in Source Code+ Form, as described in Section 3.1, and You must inform recipients of+ the Executable Form how they can obtain a copy of such Source Code+ Form by reasonable means in a timely manner, at a charge no more+ than the cost of distribution to the recipient; and++(b) You may distribute such Executable Form under the terms of this+ License, or sublicense it under different terms, provided that the+ license for the Executable Form does not attempt to limit or alter+ the recipients' rights in the Source Code Form under this License.++3.3. Distribution of a Larger Work++You may create and distribute a Larger Work under terms of Your choice,+provided that You also comply with the requirements of this License for+the Covered Software. If the Larger Work is a combination of Covered+Software with a work governed by one or more Secondary Licenses, and the+Covered Software is not Incompatible With Secondary Licenses, this+License permits You to additionally distribute such Covered Software+under the terms of such Secondary License(s), so that the recipient of+the Larger Work may, at their option, further distribute the Covered+Software under the terms of either this License or such Secondary+License(s).++3.4. Notices++You may not remove or alter the substance of any license notices+(including copyright notices, patent notices, disclaimers of warranty,+or limitations of liability) contained within the Source Code Form of+the Covered Software, except that You may alter any license notices to+the extent required to remedy known factual inaccuracies.++3.5. Application of Additional Terms++You may choose to offer, and to charge a fee for, warranty, support,+indemnity or liability obligations to one or more recipients of Covered+Software. However, You may do so only on Your own behalf, and not on+behalf of any Contributor. You must make it absolutely clear that any+such warranty, support, indemnity, or liability obligation is offered by+You alone, and You hereby agree to indemnify every Contributor for any+liability incurred by such Contributor as a result of warranty, support,+indemnity or liability terms You offer. You may include additional+disclaimers of warranty and limitations of liability specific to any+jurisdiction.++4. Inability to Comply Due to Statute or Regulation+---------------------------------------------------++If it is impossible for You to comply with any of the terms of this+License with respect to some or all of the Covered Software due to+statute, judicial order, or regulation then You must: (a) comply with+the terms of this License to the maximum extent possible; and (b)+describe the limitations and the code they affect. Such description must+be placed in a text file included with all distributions of the Covered+Software under this License. Except to the extent prohibited by statute+or regulation, such description must be sufficiently detailed for a+recipient of ordinary skill to be able to understand it.++5. Termination+--------------++5.1. The rights granted under this License will terminate automatically+if You fail to comply with any of its terms. However, if You become+compliant, then the rights granted under this License from a particular+Contributor are reinstated (a) provisionally, unless and until such+Contributor explicitly and finally terminates Your grants, and (b) on an+ongoing basis, if such Contributor fails to notify You of the+non-compliance by some reasonable means prior to 60 days after You have+come back into compliance. Moreover, Your grants from a particular+Contributor are reinstated on an ongoing basis if such Contributor+notifies You of the non-compliance by some reasonable means, this is the+first time You have received notice of non-compliance with this License+from such Contributor, and You become compliant prior to 30 days after+Your receipt of the notice.++5.2. If You initiate litigation against any entity by asserting a patent+infringement claim (excluding declaratory judgment actions,+counter-claims, and cross-claims) alleging that a Contributor Version+directly or indirectly infringes any patent, then the rights granted to+You by any and all Contributors for the Covered Software under Section+2.1 of this License shall terminate.++5.3. In the event of termination under Sections 5.1 or 5.2 above, all+end user license agreements (excluding distributors and resellers) which+have been validly granted by You or Your distributors under this License+prior to termination shall survive termination.++************************************************************************+* *+* 6. Disclaimer of Warranty *+* ------------------------- *+* *+* Covered Software is provided under this License on an "as is" *+* basis, without warranty of any kind, either expressed, implied, or *+* statutory, including, without limitation, warranties that the *+* Covered Software is free of defects, merchantable, fit for a *+* particular purpose or non-infringing. The entire risk as to the *+* quality and performance of the Covered Software is with You. *+* Should any Covered Software prove defective in any respect, You *+* (not any Contributor) assume the cost of any necessary servicing, *+* repair, or correction. This disclaimer of warranty constitutes an *+* essential part of this License. No use of any Covered Software is *+* authorized under this License except under this disclaimer. *+* *+************************************************************************++************************************************************************+* *+* 7. Limitation of Liability *+* -------------------------- *+* *+* Under no circumstances and under no legal theory, whether tort *+* (including negligence), contract, or otherwise, shall any *+* Contributor, or anyone who distributes Covered Software as *+* permitted above, be liable to You for any direct, indirect, *+* special, incidental, or consequential damages of any character *+* including, without limitation, damages for lost profits, loss of *+* goodwill, work stoppage, computer failure or malfunction, or any *+* and all other commercial damages or losses, even if such party *+* shall have been informed of the possibility of such damages. This *+* limitation of liability shall not apply to liability for death or *+* personal injury resulting from such party's negligence to the *+* extent applicable law prohibits such limitation. Some *+* jurisdictions do not allow the exclusion or limitation of *+* incidental or consequential damages, so this exclusion and *+* limitation may not apply to You. *+* *+************************************************************************++8. Litigation+-------------++Any litigation relating to this License may be brought only in the+courts of a jurisdiction where the defendant maintains its principal+place of business and such litigation shall be governed by laws of that+jurisdiction, without reference to its conflict-of-law provisions.+Nothing in this Section shall prevent a party's ability to bring+cross-claims or counter-claims.++9. Miscellaneous+----------------++This License represents the complete agreement concerning the subject+matter hereof. If any provision of this License is held to be+unenforceable, such provision shall be reformed only to the extent+necessary to make it enforceable. Any law or regulation which provides+that the language of a contract shall be construed against the drafter+shall not be used to construe this License against a Contributor.++10. Versions of the License+---------------------------++10.1. New Versions++Mozilla Foundation is the license steward. Except as provided in Section+10.3, no one other than the license steward has the right to modify or+publish new versions of this License. Each version will be given a+distinguishing version number.++10.2. Effect of New Versions++You may distribute the Covered Software under the terms of the version+of the License under which You originally received the Covered Software,+or under the terms of any subsequent version published by the license+steward.++10.3. Modified Versions++If you create software not governed by this License, and you want to+create a new license for such software, you may create and use a+modified version of this License if you rename the license and remove+any references to the name of the license steward (except to note that+such modified license differs from this License).++10.4. Distributing Source Code Form that is Incompatible With Secondary+Licenses++If You choose to distribute Source Code Form that is Incompatible With+Secondary Licenses under the terms of this version of the License, the+notice described in Exhibit B of this License must be attached.++Exhibit A - Source Code Form License Notice+-------------------------------------------++ This Source Code Form is subject to the terms of the Mozilla Public+ License, v. 2.0. If a copy of the MPL was not distributed with this+ file, You can obtain one at https://mozilla.org/MPL/2.0/.++If it is not possible or desirable to put the notice in a particular+file, then You may include the notice in a location (such as a LICENSE+file in a relevant directory) where a recipient would be likely to look+for such a notice.++You may add additional accurate notices of copyright ownership.++Exhibit B - "Incompatible With Secondary Licenses" Notice+---------------------------------------------------------++ This Source Code Form is "Incompatible With Secondary Licenses", as+ defined by the Mozilla Public License, v. 2.0.
+ README.md view
@@ -0,0 +1,200 @@+# oauth2-server — OAuth 2.1 Authorization Server for Servant++`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.++## Features++- 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 `FormAuth` typeclass+- In‑memory refresh token persistence by default, with an interface to plug your own store++## Endpoints++- `GET /.well-known/oauth-authorization-server` — discovery metadata+- `GET /authorize` — start authorization (renders a login form)+- `POST /authorize/callback` — handles login form submission and issues authorization codes+- `POST /token` — exchanges codes for tokens and refreshes tokens+- `POST /register` — dynamic client registration++PKCE is enforced for all clients using the authorization code grant. Use either `S256` or `plain` as the challenge method.++## Install++Add the package to your library or executable stanza:++```cabal+build-depends:+ base+ , servant+ , servant-server+ , servant-auth-server+ , blaze-html+ , text+ , aeson+ , containers+ , oauth2-server -- this package+```++This project targets GHC 9.12 (see `cabal.project`).++## Quick Start++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.++```haskell+{-# 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 8080+```++Notes:++- `initOAuthState` sets the base URL and port used in discovery metadata.+- Pass `defaultLoginFormRenderer` for the built-in login page, or supply your own `LoginFormParams -> Html` function to customise the look-and-feel.+- For production, provide a durable `RefreshTokenPersistence` (e.g. database) by implementing `persistRefreshToken`, `deleteRefreshToken`, and `lookupRefreshToken`.+- The default login page references `/static/logo.png` if present; it's optional.++## Short Tutorial++This walkthrough registers a client, runs a PKCE authorization code flow, exchanges the code for tokens, and refreshes the token. Replace placeholders as needed.++1) Register a client++```bash+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_...", ... }+```++2) 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`.++3) Exchange the code for tokens++```bash+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" }+```++4) Refresh the access token (rotation)++```bash+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": "..." }+```++5) Discover server metadata (optional)++```bash+curl -s http://localhost:8080/.well-known/oauth-authorization-server | jq+```++## Key Types and APIs++- `OAuthAPI` — combined Servant API for all OAuth endpoints+- `oAuthAPI :: MVar (OAuthState usr) -> Context ctxt -> Server OAuthAPI` — server implementation+- `FormAuth usr` — plug‑in credential verification for your user type:+ - associated type `FormAuthSettings usr`+ - `runFormAuth :: Context ctxt -> Text -> Text -> IO (AuthResult usr)`+- `OAuthState usr` — holds authorization codes, client registry, refresh‑token persistence, and login form renderer+- `mkDefaultRefreshTokenPersistence` — in‑memory `RefreshToken` storage (replace in production)+- `initOAuthState` — construct initial state with base URL, port, and login form renderer+- `defaultLoginFormRenderer` — built-in login page; pass to `initOAuthState` or replace with your own+- `LoginFormParams` — parameters available to a custom login form renderer++See also:++- `tests/OAuth/FlowSpec.hs` — end‑to‑end flow covering registration, authorization, token exchange, and refresh+- `tests/OAuth/TestUtils.hs` — example `FormAuth` instance and JWT configuration++## Development++Run tests:++```bash+cabal build+cabal test+```++## Security Notes++- Always serve over HTTPS in production. Set `oauth_url` accordingly.+- Use a strong JWT signing key and appropriate token lifetimes.+- PKCE is required for authorization code exchanges. Prefer `S256` in real clients.+- The default refresh‑token store is in‑memory and not durable; implement your own for production.++## License++See `LICENSE` in this repository.
+ oauth2-server.cabal view
@@ -0,0 +1,132 @@+cabal-version: 2.2+name: oauth2-server+version: 0.3.0.0+license: MPL-2.0+license-file: LICENSE+author: DPella AB+extra-doc-files: CHANGELOG.md, README.md+maintainer: matti@dpella.io,+ lobo@dpella.io+synopsis: OAuth 2.1 authorization server implementation+description:+ Provides a cohesive Servant implementation of the OAuth 2.1 surface, covering+ discovery metadata, authorization and callback flows, token issuance, and dynamic+ client registration. The package models authorization codes, refresh tokens, and+ registered clients with PKCE enforcement, configurable persistence backends, and+ secure token generation helpers. Combined servers share state through `MVar`s,+ integrate with `servant-auth-server` to mint JWT access tokens, and expose ergonomic+ helpers for embedding standards-compliant OAuth flows into Haskell applications.+homepage: https://github.com/DPella/oauth2-server+bug-reports: https://github.com/DPella/oauth2-server/issues+category: Web, Security++source-repository head+ type: git+ location: https://github.com/DPella/oauth2-server.git++library+ default-language:+ Haskell2010+ default-extensions:+ DataKinds+ , FlexibleInstances+ , GADTs+ , GeneralizedNewtypeDeriving+ , ImportQualifiedPost+ , LambdaCase+ , OverloadedStrings+ , PolyKinds+ , RankNTypes+ , TypeApplications+ , TypeFamilies+ hs-source-dirs:+ src+ exposed-modules:+ Web.OAuth2+ Web.OAuth2.Types+ Web.OAuth2.Internal+ other-modules:+ Web.OAuth2.TokenAPI+ Web.OAuth2.AuthorizeAPI+ Web.OAuth2.AuthorizeCallbackAPI+ Web.OAuth2.RegisterAPI+ Web.OAuth2.MetadataAPI+ build-depends:+ base >=4.18 && <4.22+ , servant >=0.20 && <0.21+ , servant-server >=0.20 && <0.21+ , servant-auth-server >=0.4.9 && <0.5+ , servant-blaze >=0.9 && <0.10+ , text >=2.0 && <2.2+ , bytestring >=0.11 && <0.13+ , aeson >=2.1 && <2.3+ , containers >=0.6 && <0.8+ , time >=1.12 && <1.15+ , random >=1.2 && <1.4+ , blaze-html >=0.9 && <0.10+ , network-uri >=2.6 && <2.7+ , http-api-data >=0.5 && <0.7+ , http-types >=0.12 && <0.13+ , cryptonite >=0.29 && <0.31+ , memory >=0.17 && <0.19+ , base64-bytestring >=1.2 && <1.3+ , transformers >=0.5 && <0.7++test-suite test-oauth2-server+ type: exitcode-stdio-1.0+ default-language:+ Haskell2010+ default-extensions:+ DataKinds+ , FlexibleInstances+ , GADTs+ , GeneralizedNewtypeDeriving+ , ImportQualifiedPost+ , LambdaCase+ , OverloadedStrings+ , PolyKinds+ , RankNTypes+ , TypeApplications+ hs-source-dirs:+ test+ main-is:+ Main.hs+ other-modules:+ Web.OAuth2.AuthorizeCallbackSpec+ Web.OAuth2.AuthorizeSpec+ Web.OAuth2.FlowSpec+ Web.OAuth2.MetadataSpec+ Web.OAuth2.RegisterSpec+ Web.OAuth2.TestUtils+ Web.OAuth2.TokenSpec+ build-depends:+ base >=4.18 && <4.22+ , oauth2-server+ , aeson+ , text+ , bytestring+ , containers+ , time+ , async+ , tasty+ , tasty-hunit+ , tasty-quickcheck+ , QuickCheck+ , servant+ , servant-server+ , servant-auth-server+ , servant-blaze+ , network-uri+ , http-api-data+ , wai+ , wai-extra+ , http-types+ , http-client+ , warp+ , cryptonite+ , memory+ , scientific+ , base64-bytestring+ , blaze-html+ , blaze-markup+ , transformers
+ src/Web/OAuth2.hs view
@@ -0,0 +1,57 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE TypeOperators #-}++-- |+-- Module: Web.OAuth2+-- Copyright: (c) DPella AB 2025+-- License: MPL-2.0+-- Maintainer: <matti@dpella.io>, <lobo@dpella.io>+--+-- OAuth 2.1 server implementation module.+--+-- This module combines all OAuth endpoints into a single API type+-- and provides the server implementation that routes requests to+-- the appropriate handlers.+module Web.OAuth2 (OAuthAPI, oAuthAPI, mkDefaultRefreshTokenPersistence, defaultLoginFormRenderer) where++import Control.Concurrent.MVar (MVar)+import Web.OAuth2.AuthorizeAPI+import Web.OAuth2.AuthorizeCallbackAPI+import Web.OAuth2.MetadataAPI+import Web.OAuth2.RegisterAPI+import Web.OAuth2.TokenAPI+import Web.OAuth2.Types+import Servant+import Servant.Auth.Server++-- | Combined OAuth 2.1 API type.+--+-- Includes all OAuth endpoints:+-- * Metadata discovery endpoint+-- * Authorization endpoint+-- * Authorization callback endpoint+-- * Token exchange endpoint+-- * Dynamic client registration endpoint+type OAuthAPI = MetadataAPI :<|> AuthorizeAPI :<|> AuthorizeCallbackAPI :<|> TokenAPI :<|> RegisterAPI++-- | OAuth server implementation.+--+-- Takes an MVar containing the OAuth server state and returns+-- a Servant server that handles all OAuth endpoints. The state+-- is shared across all handlers to maintain authorization codes,+-- refresh tokens, and client registrations.+oAuthAPI+ :: ( FormAuth usr+ , ToJWT usr+ , HasContextEntry ctxt JWTSettings+ , HasContextEntry ctxt (FormAuthSettings usr)+ )+ => MVar (OAuthState usr)+ -> Context ctxt+ -> Server OAuthAPI+oAuthAPI state_var ctxt =+ handleMetadata state_var+ :<|> handleAuthorize state_var+ :<|> handleAuthorizeCallback state_var ctxt+ :<|> handleTokenRequest state_var ctxt+ :<|> registerServer state_var
+ src/Web/OAuth2/AuthorizeAPI.hs view
@@ -0,0 +1,244 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TypeOperators #-}++-- |+-- Module: Web.OAuth2.AuthorizeAPI+-- Copyright: (c) DPella AB 2025+-- License: MPL-2.0+-- Maintainer: <matti@dpella.io>, <lobo@dpella.io>+--+-- OAuth 2.1 Authorization Endpoint.+--+-- This module implements the OAuth 2.1 authorization endpoint that handles+-- the initial step of the authorization code flow. It presents a login form+-- to users and validates their credentials against the configured authentication+-- backend.+--+-- The authorization flow follows RFC 6749 with PKCE (RFC 7636) support for+-- enhanced security in public clients.+module Web.OAuth2.AuthorizeAPI where++import Control.Concurrent.MVar+import Control.Monad (forM_)+import Control.Monad.IO.Class (liftIO)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Network.URI (escapeURIString, isUnescapedInURIComponent)+import Web.OAuth2.Types+import Servant+import Servant.HTML.Blaze+import Text.Blaze.Html5 qualified as H+import Text.Blaze.Html5.Attributes qualified as A+import Prelude hiding (error)++-- | Servant API type for the OAuth 2.1 authorization endpoint.+--+-- Query parameters:+-- * @response_type@ - Must be "code" for authorization code flow+-- * @client_id@ - The client identifier issued during registration+-- * @redirect_uri@ - Where to redirect after authorization+-- * @scope@ - Space-delimited list of requested scopes+-- * @state@ - Opaque value to maintain state between request and callback+-- * @code_challenge@ - PKCE challenge for public clients (RFC 7636)+-- * @code_challenge_method@ - Either "S256" or "plain" for PKCE+-- * @error@ - Error message to display (used for login retry)+type AuthorizeAPI =+ "authorize"+ :> QueryParam "response_type" Text+ :> QueryParam "client_id" Text+ :> QueryParam "redirect_uri" Text+ :> QueryParam "scope" Text+ :> QueryParam "state" Text+ :> QueryParam "code_challenge" Text+ :> QueryParam "code_challenge_method" Text+ :> QueryParam "error" Text+ :> Get '[HTML] H.Html++-- | Handle OAuth 2.1 authorization requests.+--+-- This function validates the authorization request parameters and displays+-- a login form to the user. It performs the following validations:+--+-- 1. Ensures @response_type@ is "code" (only authorization code flow is supported)+-- 2. Validates that the client is registered+-- 3. Checks that the redirect URI matches one registered for the client+-- 4. Validates that requested scopes are allowed for the client+--+-- If all validations pass, it returns an HTML login form. The form will POST+-- credentials to the callback endpoint for authentication.+handleAuthorize+ :: forall usr+ . MVar (OAuthState usr)+ -> Maybe Text+ -- ^ response_type parameter+ -> Maybe Text+ -- ^ client_id parameter+ -> Maybe Text+ -- ^ redirect_uri parameter+ -> Maybe Text+ -- ^ scope parameter+ -> Maybe Text+ -- ^ state parameter+ -> Maybe Text+ -- ^ code_challenge parameter (PKCE)+ -> Maybe Text+ -- ^ code_challenge_method parameter (PKCE)+ -> Maybe Text+ -- ^ error parameter (for retry display)+ -> Handler H.Html+handleAuthorize+ state_var+ responseType+ clientIdParam+ redirectParam+ mb_scope+ mb_state+ code_challenge+ code_challenge_method+ error_msg = do+ case responseType of+ Nothing -> invalidRequest "Missing response_type parameter"+ Just "code" -> pure ()+ Just _ -> unsupportedResponseType "Only response_type=code is supported"+ cid <- maybe (invalidRequest "Missing client_id parameter") pure clientIdParam+ redirect_uri <- maybe (invalidRequest "Missing redirect_uri parameter") pure redirectParam+ oauth_state <- liftIO $ readMVar state_var+ case Map.lookup cid (registered_clients oauth_state) of+ Just RegisteredClient{..} ->+ if redirect_uri `elem` registered_client_redirect_uris+ then+ if validateScope scope registered_client_scope+ then do+ let params = LoginFormParams+ { lfp_client_id = cid+ , lfp_redirect_uri = redirect_uri+ , lfp_scope = scope+ , lfp_state = mb_state+ , lfp_code_challenge = code_challenge+ , lfp_code_challenge_method = code_challenge_method+ , lfp_error = error_msg+ }+ return (login_form_renderer oauth_state params)+ else redirectWithError redirect_uri "invalid_scope" (Just "Requested scope is not allowed for this client")+ else badRequest "unauthorized_client" "Invalid redirect URI for client"+ Nothing -> authError "unauthorized_client" "Client not registered or invalid client_id"+ where+ invalidRequest :: Text -> Handler a+ invalidRequest desc =+ throwError $ oauthErrorResponse err400 "invalid_request" (Just desc)++ unsupportedResponseType :: Text -> Handler a+ unsupportedResponseType desc =+ throwError $ oauthErrorResponse err400 "unsupported_response_type" (Just desc)++ badRequest :: Text -> Text -> Handler H.Html+ badRequest error_code desc =+ throwError $ oauthErrorResponse err400 error_code (Just desc)++ authError :: Text -> Text -> Handler H.Html+ authError error_code desc =+ throwError $ oauthErrorResponse err401 error_code (Just desc)++ scope = fromMaybe "" mb_scope++ redirectWithError :: Text -> Text -> Maybe Text -> Handler a+ redirectWithError redirectUri errorCode errorDescription =+ let params =+ [("error", errorCode)]+ <> maybeParam "error_description" errorDescription+ <> maybeParam "state" mb_state+ location = buildRedirectUrl redirectUri params+ in 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++-- | Validate that the requested scope is a subset of the client's allowed scopes.+--+-- Scopes are space-delimited strings. This function checks that every scope+-- requested by the client is present in the list of allowed scopes.+--+-- >>> validateScope "read" "read write admin"+-- True+--+-- >>> validateScope "read write" "read"+-- False+validateScope :: Text -> Text -> Bool+validateScope requested allowed =+ let requested_scopes = T.words requested+ allowed_scopes = T.words allowed+ in not (null requested_scopes) && all (`elem` allowed_scopes) requested_scopes++-- | Default login form renderer.+--+-- Produces a self-contained HTML page with inline CSS that displays a+-- centred login box, optional error banner, and username\/password fields.+-- The form POSTs to @authorize\/callback@.+--+-- Pass this to 'initOAuthState' when you do not need a custom login page.+defaultLoginFormRenderer :: LoginFormParams -> H.Html+defaultLoginFormRenderer LoginFormParams{..} = H.docTypeHtml $ do+ H.head $ do+ H.title "OAuth Login"+ H.style $+ H.toHtml $+ T.unlines+ [ "body { font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f0f0f0; }"+ , ".login-box { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); width: 300px; text-align: center; }"+ , ".logo { width: 80px; height: 80px; margin: 0 auto 1.5rem; display: block; }"+ , "h2 { margin-top: 0; margin-bottom: 1.5rem; color: #333; }"+ , "input { width: 100%; padding: 0.5rem; margin: 0.5rem 0; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }"+ , "button { width: 100%; padding: 0.75rem; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; margin-top: 1rem; }"+ , "button:hover { background-color: #0056b3; }"+ , ".form-group { text-align: left; }"+ , ".error-message { background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; }"+ ]+ H.body $ do+ H.div H.! A.class_ "login-box" $ do+ H.img H.! A.src "/static/logo.png" H.! A.alt "Logo" H.! A.class_ "logo"+ H.h2 "Sign In"+ case lfp_error of+ Just "invalid_password" -> H.div H.! A.class_ "error-message" $ "Invalid username or password"+ Just err -> H.div H.! A.class_ "error-message" $ H.toHtml err+ Nothing -> mempty+ H.form H.! A.method "post" H.! A.action "authorize/callback" $ do+ H.input H.! A.type_ "hidden" H.! A.name "client_id" H.! A.value (H.toValue lfp_client_id)+ H.input H.! A.type_ "hidden" H.! A.name "redirect_uri" H.! A.value (H.toValue lfp_redirect_uri)+ H.input H.! A.type_ "hidden" H.! A.name "scope" H.! A.value (H.toValue lfp_scope)+ forM_ lfp_state $ \st ->+ H.input H.! A.type_ "hidden" H.! A.name "state" H.! A.value (H.toValue st)+ forM_ lfp_code_challenge $ \cc ->+ H.input H.! A.type_ "hidden" H.! A.name "code_challenge" H.! A.value (H.toValue cc)+ forM_ lfp_code_challenge_method $ \ccm ->+ H.input H.! A.type_ "hidden" H.! A.name "code_challenge_method" H.! A.value (H.toValue ccm)++ H.div H.! A.class_ "form-group" $ do+ H.input H.! A.type_ "text" H.! A.name "username" H.! A.placeholder "Username" H.! A.required ""+ H.input H.! A.type_ "password" H.! A.name "password" H.! A.placeholder "Password" H.! A.required ""++ H.button H.! A.type_ "submit" $ "Sign In"
+ src/Web/OAuth2/AuthorizeCallbackAPI.hs view
@@ -0,0 +1,197 @@+{-# 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")
+ src/Web/OAuth2/Internal.hs view
@@ -0,0 +1,45 @@+-- |+-- Module: Web.OAuth2.Internal+-- Description: Project-internal surface area for testing.+--+-- This module exposes a minimal collection of internal functions+-- and data constructors that the test suite exercises. External+-- callers should treat this interface as unstable.+module Web.OAuth2.Internal+ ( validateScope+ , defaultLoginFormRenderer+ , LoginForm (..)+ , handleAuthorizeCallback+ , handleTokenRequest+ , TokenRequest (..)+ , TokenResponse (TokenResponse, access_token, token_type, expires_in, refresh_token_resp)+ , TokenResponseHeaders+ , handleRegister+ , registerServer+ , handleRegistrationGet+ , handleRegistrationUpdate+ , handleRegistrationDelete+ , ClientRegistrationRequest (..)+ , ClientRegistrationResponse (..)+ , handleMetadata+ , OAuthMetadata (..)+ ) where++import Web.OAuth2.AuthorizeAPI (defaultLoginFormRenderer, validateScope)+import Web.OAuth2.AuthorizeCallbackAPI (LoginForm (..), handleAuthorizeCallback)+import Web.OAuth2.MetadataAPI (OAuthMetadata (..), handleMetadata)+import Web.OAuth2.RegisterAPI+ ( ClientRegistrationRequest (..)+ , ClientRegistrationResponse (..)+ , handleRegister+ , handleRegistrationDelete+ , handleRegistrationGet+ , handleRegistrationUpdate+ , registerServer+ )+import Web.OAuth2.TokenAPI+ ( TokenRequest (..)+ , TokenResponse (TokenResponse, access_token, token_type, expires_in, refresh_token_resp)+ , TokenResponseHeaders+ , handleTokenRequest+ )
+ src/Web/OAuth2/MetadataAPI.hs view
@@ -0,0 +1,105 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TypeOperators #-}++-- |+-- Module: Web.OAuth2.MetadataAPI+-- Copyright: (c) DPella AB 2025+-- License: MPL-2.0+-- Maintainer: <matti@dpella.io>, <lobo@dpella.io>+--+-- OAuth 2.1 Authorization Server Metadata Endpoint.+--+-- This module implements the OAuth 2.1 Authorization Server Metadata+-- endpoint as defined in RFC 8414. It provides clients with information+-- about the authorization server's capabilities and endpoints.+--+-- The metadata helps clients discover:+-- * Authorization and token endpoints+-- * Supported grant types and response types+-- * Authentication methods+-- * PKCE support+-- * Available scopes+module Web.OAuth2.MetadataAPI where++import Control.Concurrent.MVar+import Control.Monad.IO.Class (liftIO)+import Data.Aeson+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics+import Servant+import Web.OAuth2.Types++-- | OAuth 2.1 Authorization Server Metadata structure.+--+-- This structure contains all the metadata fields defined in RFC 8414+-- that are relevant for this OAuth implementation.+data OAuthMetadata = OAuthMetadata+ { issuer :: Text+ -- ^ The authorization server's issuer identifier+ , authorization_endpoint :: Text+ -- ^ URL of the authorization endpoint+ , token_endpoint :: Text+ -- ^ URL of the token endpoint+ , registration_endpoint :: Text+ -- ^ URL of the dynamic client registration endpoint+ , grant_types_supported :: [Text]+ -- ^ List of supported OAuth 2.1 grant types+ , response_types_supported :: [Text]+ -- ^ List of supported response types+ , token_endpoint_auth_methods_supported :: [Text]+ -- ^ List of client authentication methods supported at token endpoint+ , code_challenge_methods_supported :: [Text]+ -- ^ List of PKCE code challenge methods supported+ , scopes_supported :: [Text]+ -- ^ List of supported scope values+ }+ deriving (Generic, Show)++instance ToJSON OAuthMetadata++-- | Servant API type for the OAuth metadata endpoint.+--+-- The endpoint is available at @/.well-known/oauth-authorization-server@+-- as specified in RFC 8414.+type MetadataAPI = ".well-known" :> "oauth-authorization-server" :> Get '[JSON] OAuthMetadata++-- | Handle requests for OAuth server metadata.+--+-- Returns a JSON object describing the capabilities and endpoints+-- of the OAuth authorization server. The metadata includes:+--+-- * Server issuer URL constructed from base URL and port+-- * All OAuth endpoints (authorize, token, register)+-- * Supported grant types: "authorization_code" and "refresh_token"+-- * PKCE support with "S256" and "plain" methods+-- * Available scopes: "read" and "write"+handleMetadata :: forall usr. MVar (OAuthState usr) -> Handler OAuthMetadata+handleMetadata state_var = do+ OAuthState{..} <- liftIO $ readMVar state_var+ let baseUrl = normalizeBaseUrl oauth_url oauth_port+ baseForPaths = stripTrailingSlash baseUrl+ scopeTokens =+ Set.toList $+ Set.fromList $+ concatMap (T.words . registered_client_scope) (Map.elems registered_clients)+ scopesSupported =+ if null scopeTokens+ then ["read", "write"]+ else scopeTokens+ return $+ OAuthMetadata+ { issuer = baseUrl+ , authorization_endpoint = appendPathSegment baseForPaths "/authorize"+ , token_endpoint = appendPathSegment baseForPaths "/token"+ , registration_endpoint = appendPathSegment baseForPaths "/register"+ , grant_types_supported = ["authorization_code", "refresh_token"]+ , response_types_supported = ["code"]+ , token_endpoint_auth_methods_supported = ["none", "client_secret_post"]+ , code_challenge_methods_supported = ["S256", "plain"]+ , scopes_supported = scopesSupported+ }
+ src/Web/OAuth2/RegisterAPI.hs view
@@ -0,0 +1,447 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TypeOperators #-}++-- |+-- Module: Web.OAuth2.RegisterAPI+-- Copyright: (c) DPella AB 2025+-- License: MPL-2.0+-- Maintainer: <matti@dpella.io>, <lobo@dpella.io>+--+-- OAuth 2.1 Dynamic Client Registration (RFC 7591).+--+-- This module implements dynamic client registration as defined in RFC 7591.+-- It allows OAuth clients to register themselves programmatically with the+-- authorization server.+--+-- Registered clients receive a unique client_id that must be used in all+-- subsequent OAuth flows. The registration process collects client metadata+-- including redirect URIs, grant types, and requested scopes.+module Web.OAuth2.RegisterAPI where++import Control.Concurrent.MVar+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)+import Data.Aeson+import Data.ByteString.Char8 qualified as BS8+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics+import Network.URI qualified as URI+import Web.OAuth2.Types+import Servant+import Text.Read (readMaybe)++-- | Servant API type for the OAuth dynamic client registration endpoint.+--+-- Accepts a JSON request body with client metadata and returns+-- the registered client information including the assigned client_id.+type RegisterAPI =+ "register"+ :> ReqBody '[JSON] ClientRegistrationRequest+ :> PostCreated '[JSON] ClientRegistrationResponse+ :<|>+ "register"+ :> Capture "client_id" Text+ :> Header "Authorization" Text+ :> Get '[JSON] ClientRegistrationResponse+ :<|>+ "register"+ :> Capture "client_id" Text+ :> Header "Authorization" Text+ :> ReqBody '[JSON] ClientRegistrationRequest+ :> Put '[JSON] ClientRegistrationResponse+ :<|>+ "register"+ :> Capture "client_id" Text+ :> Header "Authorization" Text+ :> Verb 'DELETE 204 '[JSON] NoContent++-- | Request payload for dynamic client registration.+--+-- Contains the client metadata that should be registered with+-- the authorization server. Most fields are optional and will+-- use sensible defaults if not provided.+data ClientRegistrationRequest = ClientRegistrationRequest+ { client_name :: Text+ -- ^ Human-readable name of the client+ , redirect_uris :: [Text]+ -- ^ List of allowed redirect URIs for this client+ , grant_types :: Maybe [Text]+ -- ^ OAuth grant types the client will use (default: ["authorization_code", "refresh_token"])+ , response_types :: Maybe [Text]+ -- ^ OAuth response types the client will use (default: ["code"])+ , scope :: Maybe Text+ -- ^ Space-delimited list of scopes the client may request (default: "read write")+ , token_endpoint_auth_method :: Maybe Text+ -- ^ Client authentication method at token endpoint (default: "none")+ }+ deriving (Eq, Show, Generic)++instance FromJSON ClientRegistrationRequest++instance ToJSON ClientRegistrationRequest++-- | Response returned after successful client registration.+--+-- Contains all the registered client metadata including the+-- newly assigned client_id that must be used in OAuth flows.+data ClientRegistrationResponse = ClientRegistrationResponse+ { reg_client_id :: Text+ -- ^ Unique identifier assigned to the client+ , reg_client_name :: Text+ -- ^ Registered human-readable name+ , reg_client_secret :: Maybe Text+ -- ^ Secret issued to confidential clients (Nothing for public clients)+ , reg_client_secret_expires_at :: Maybe Int+ -- ^ Epoch seconds when the secret expires (Nothing means unspecified)+ , reg_redirect_uris :: [Text]+ -- ^ Registered redirect URIs+ , reg_grant_types :: [Text]+ -- ^ Allowed grant types for this client+ , reg_response_types :: [Text]+ -- ^ Allowed response types for this client+ , reg_scope :: Text+ -- ^ Maximum scope this client can request+ , reg_token_endpoint_auth_method :: Text+ -- ^ Required authentication method at token endpoint+ , reg_registration_access_token :: Text+ -- ^ Registration access token for subsequent client management+ , reg_registration_client_uri :: Text+ -- ^ URI where the client configuration can be managed+ }+ deriving (Eq, Show, Generic)++instance ToJSON ClientRegistrationResponse where+ toJSON ClientRegistrationResponse{..} =+ object $+ [ "client_id" .= reg_client_id+ , "client_name" .= reg_client_name+ , "redirect_uris" .= reg_redirect_uris+ , "grant_types" .= reg_grant_types+ , "response_types" .= reg_response_types+ , "scope" .= reg_scope+ , "token_endpoint_auth_method" .= reg_token_endpoint_auth_method+ , "registration_access_token" .= reg_registration_access_token+ , "registration_client_uri" .= reg_registration_client_uri+ ]+ <> maybe [] (\secret -> ["client_secret" .= secret]) reg_client_secret+ <> maybe [] (\expiry -> ["client_secret_expires_at" .= expiry]) reg_client_secret_expires_at++-- | Handle dynamic client registration requests.+--+-- This function:+--+-- 1. Generates a unique client_id with "client_" prefix+-- 2. Applies defaults for any optional parameters:+-- - grant_types: ["authorization_code", "refresh_token"]+-- - response_types: ["code"]+-- - scope: "read write"+-- - auth_method: "none"+-- 3. Stores the client registration in the OAuth state+-- 4. Returns the complete client registration details+--+registerServer :: forall usr. MVar (OAuthState usr) -> Server RegisterAPI+registerServer state_var =+ handleRegister state_var+ :<|> handleRegistrationGet state_var+ :<|> handleRegistrationUpdate state_var+ :<|> handleRegistrationDelete state_var++data NormalizedRegistration = NormalizedRegistration+ { normGrantTypes :: [Text]+ , normResponseTypes :: [Text]+ , normScope :: Text+ , normAuthMethod :: Text+ , normSecret :: Maybe Text+ }++-- | Handle dynamic client registration requests.+--+-- The client_id is generated using a secure random token generator.+handleRegister :: forall usr. MVar (OAuthState usr) -> ClientRegistrationRequest -> Handler ClientRegistrationResponse+handleRegister state_var request@ClientRegistrationRequest{..} = do+ normalized <- liftEitherIO =<< liftIO (normalizeRegistration Nothing request)+ client_id <- ("client_" <>) <$> liftIO generateToken+ registration_access_token <- liftIO generateToken+ response <-+ liftIO $+ modifyMVar state_var $ \s -> do+ let baseUrl = normalizeBaseUrl (oauth_url s) (oauth_port s)+ baseForPaths = stripTrailingSlash baseUrl+ registration_client_uri = appendPathSegment baseForPaths ("/register/" <> client_id)+ newClient =+ RegisteredClient+ { registered_client_id = client_id+ , registered_client_name = client_name+ , registered_client_secret = normSecret normalized+ , registered_client_redirect_uris = redirect_uris+ , registered_client_grant_types = normGrantTypes normalized+ , registered_client_response_types = normResponseTypes normalized+ , registered_client_scope = normScope normalized+ , registered_client_token_endpoint_auth_method = normAuthMethod normalized+ , registered_client_registration_access_token = Just registration_access_token+ }+ updatedState =+ s+ { registered_clients = Map.insert client_id newClient (registered_clients s)+ }+ responseValue =+ buildRegistrationResponse registration_client_uri registration_access_token newClient+ pure (updatedState, responseValue)+ pure response++-- | Retrieve metadata for an existing client registration (RFC 7592).+handleRegistrationGet+ :: forall usr+ . MVar (OAuthState usr)+ -> Text+ -> Maybe Text+ -> Handler ClientRegistrationResponse+handleRegistrationGet state_var clientId mAuth = do+ state <- liftIO $ readMVar state_var+ (baseUrl, managementToken, client) <- resolveManagedClient state clientId mAuth+ let registrationUri = appendPathSegment (stripTrailingSlash baseUrl) ("/register/" <> clientId)+ pure $ buildRegistrationResponse registrationUri managementToken client++-- | Update metadata for an existing client registration (RFC 7592).+handleRegistrationUpdate+ :: forall usr+ . MVar (OAuthState usr)+ -> Text+ -> Maybe Text+ -> ClientRegistrationRequest+ -> Handler ClientRegistrationResponse+handleRegistrationUpdate state_var clientId mAuth request@ClientRegistrationRequest{client_name = newName, redirect_uris = newRedirects} = do+ result <-+ liftIO $+ modifyMVar state_var $ \s -> do+ let baseUrl = normalizeBaseUrl (oauth_url s) (oauth_port s)+ baseForPaths = stripTrailingSlash baseUrl+ case Map.lookup clientId (registered_clients s) of+ Nothing ->+ pure (s, Left $ registrationNotFound clientId)+ Just existing -> do+ let authCheck = authorizeManagement existing mAuth+ case authCheck of+ Left err -> pure (s, Left err)+ Right token -> do+ normalizedResult <- normalizeRegistration (Just existing) request+ case normalizedResult of+ Left err -> pure (s, Left err)+ Right normalized -> do+ let registration_client_uri = appendPathSegment baseForPaths ("/register/" <> clientId)+ updatedClient =+ existing+ { registered_client_name = newName+ , registered_client_secret = normSecret normalized+ , registered_client_redirect_uris = newRedirects+ , registered_client_grant_types = normGrantTypes normalized+ , registered_client_response_types = normResponseTypes normalized+ , registered_client_scope = normScope normalized+ , registered_client_token_endpoint_auth_method = normAuthMethod normalized+ }+ newState =+ s+ { registered_clients = Map.insert clientId updatedClient (registered_clients s)+ }+ responseValue =+ buildRegistrationResponse registration_client_uri token updatedClient+ pure (newState, Right responseValue)+ either throwError pure result++-- | Remove an existing client registration (RFC 7592).+handleRegistrationDelete+ :: forall usr+ . MVar (OAuthState usr)+ -> Text+ -> Maybe Text+ -> Handler NoContent+handleRegistrationDelete state_var clientId mAuth = do+ result <-+ liftIO $+ modifyMVar state_var $ \s -> do+ case Map.lookup clientId (registered_clients s) of+ Nothing ->+ pure (s, Left $ registrationNotFound clientId)+ Just existing ->+ case authorizeManagement existing mAuth of+ Left err -> pure (s, Left err)+ Right _ ->+ let newState =+ s+ { registered_clients = Map.delete clientId (registered_clients s)+ }+ in pure (newState, Right NoContent)+ either throwError pure result++buildRegistrationResponse+ :: Text+ -> Text+ -> RegisteredClient+ -> ClientRegistrationResponse+buildRegistrationResponse registrationUri registrationToken RegisteredClient{..} =+ ClientRegistrationResponse+ { reg_client_id = registered_client_id+ , reg_client_name = registered_client_name+ , reg_client_secret = registered_client_secret+ , reg_client_secret_expires_at = Nothing+ , reg_redirect_uris = registered_client_redirect_uris+ , reg_grant_types = registered_client_grant_types+ , reg_response_types = registered_client_response_types+ , reg_scope = registered_client_scope+ , reg_token_endpoint_auth_method = registered_client_token_endpoint_auth_method+ , reg_registration_access_token = registrationToken+ , reg_registration_client_uri = registrationUri+ }++authorizeManagement :: RegisteredClient -> Maybe Text -> Either ServerError Text+authorizeManagement RegisteredClient{registered_client_registration_access_token = Nothing} _ =+ Left $ oauthErrorResponse err500 "server_error" (Just "Client missing management access token")+authorizeManagement RegisteredClient{registered_client_registration_access_token = Just expectedToken} mHeader =+ case extractBearerToken mHeader of+ Left err -> Left err+ Right provided ->+ if constTimeEq provided expectedToken+ then Right expectedToken+ else Left $ addBearerChallenge $ oauthErrorResponse err401 "invalid_token" (Just "Invalid management access token")++resolveManagedClient+ :: OAuthState usr+ -> Text+ -> Maybe Text+ -> Handler (Text, Text, RegisteredClient)+resolveManagedClient state clientId mAuth =+ case Map.lookup clientId (registered_clients state) of+ Nothing -> throwError $ registrationNotFound clientId+ Just rc ->+ case authorizeManagement rc mAuth of+ Left err -> throwError err+ Right token -> do+ let baseUrl = normalizeBaseUrl (oauth_url state) (oauth_port state)+ pure (baseUrl, token, rc)++normalizeRegistration+ :: Maybe RegisteredClient+ -> ClientRegistrationRequest+ -> IO (Either ServerError NormalizedRegistration)+normalizeRegistration existing ClientRegistrationRequest{..} = runExceptT $ do+ exceptEither $ validateRedirectUris redirect_uris+ let baseGrant = maybe ["authorization_code", "refresh_token"] registered_client_grant_types existing+ baseResponse = maybe ["code"] registered_client_response_types existing+ baseScope = maybe "read write" registered_client_scope existing+ baseAuth = maybe "none" registered_client_token_endpoint_auth_method existing+ grantTypes = fromMaybe baseGrant grant_types+ responseTypes = fromMaybe baseResponse response_types+ resolvedScope = fromMaybe baseScope scope+ resolvedAuthMethod = fromMaybe baseAuth token_endpoint_auth_method+ exceptEither $ validateAuthMethod resolvedAuthMethod+ secret <-+ case resolvedAuthMethod of+ "none" -> pure Nothing+ "client_secret_post" ->+ case existing of+ Just RegisteredClient{registered_client_secret = Just existingSecret, registered_client_token_endpoint_auth_method = "client_secret_post"} ->+ pure (Just existingSecret)+ _ -> Just <$> liftIO generateToken+ _ -> pure Nothing+ pure+ NormalizedRegistration+ { normGrantTypes = grantTypes+ , normResponseTypes = responseTypes+ , normScope = resolvedScope+ , normAuthMethod = resolvedAuthMethod+ , normSecret = secret+ }++validateAuthMethod :: Text -> Either ServerError ()+validateAuthMethod method+ | method `elem` ["none", "client_secret_post"] = Right ()+ | otherwise =+ Left $ invalidMetadataError "Unsupported token_endpoint_auth_method; expected \"none\" or \"client_secret_post\""++validateRedirectUris :: [Text] -> Either ServerError ()+validateRedirectUris uris+ | null uris = Left $ invalidMetadataError "redirect_uris must include at least one absolute URI"+ | otherwise = foldl' step (Right ()) uris+ where+ step acc uriText = acc >> checkUri uriText++ checkUri uriText =+ case URI.parseURI (T.unpack uriText) of+ Just parsed+ | not (null (URI.uriScheme parsed)) ->+ ensureNoFragment parsed >> ensureScheme parsed+ _ -> Left $ invalidMetadataError "redirect_uri is not an absolute URI"++ ensureNoFragment parsed =+ if null (URI.uriFragment parsed)+ then Right ()+ else Left $ invalidMetadataError "redirect_uris must not contain URI fragments"++ ensureScheme parsed =+ case URI.uriScheme parsed of+ "https:" -> ensureAuthority parsed+ "http:" ->+ case URI.uriAuthority parsed of+ Just auth+ | isLoopbackHost (T.pack (URI.uriRegName auth)) -> Right ()+ | otherwise -> Left $ invalidMetadataError "http redirect_uris are only allowed for loopback clients"+ Nothing -> Left $ invalidMetadataError "redirect_uris must include a network host component"+ _ -> Left $ invalidMetadataError "redirect_uris must use https scheme"++ ensureAuthority parsed =+ case URI.uriAuthority parsed of+ Just _ -> Right ()+ Nothing -> Left $ invalidMetadataError "redirect_uri must include authority (host)"++ isLoopbackHost hostName =+ hostName == "localhost"+ || hostName == "[::1]"+ || hostName == "::1"+ || isIPv4Loopback hostName++ isIPv4Loopback host =+ case traverse (readMaybe . T.unpack) (T.splitOn "." host) of+ Just [a, b, c, d]+ | a == (127 :: Int)+ , all (\o -> o >= 0 && o <= 255) [b, c, d] -> True+ _ -> False++extractBearerToken :: Maybe Text -> Either ServerError Text+extractBearerToken Nothing =+ Left $ addBearerChallenge $ oauthErrorResponse err401 "invalid_token" (Just "Missing Authorization header")+extractBearerToken (Just headerValue) =+ case T.words headerValue of+ ["Bearer", token] -> tokenOrError token+ ["bearer", token] -> tokenOrError token+ _ -> Left $ addBearerChallenge $ oauthErrorResponse err401 "invalid_token" (Just "Malformed Authorization header")+ where+ tokenOrError tok+ | T.null tok = Left $ addBearerChallenge $ oauthErrorResponse err401 "invalid_token" (Just "Missing bearer token")+ | otherwise = Right tok++registrationNotFound :: Text -> ServerError+registrationNotFound clientId =+ oauthErrorResponse err404 "invalid_client" (Just ("Unknown client_id: " <> clientId))++invalidMetadataError :: Text -> ServerError+invalidMetadataError msg =+ oauthErrorResponse err400 "invalid_client_metadata" (Just msg)++addBearerChallenge :: ServerError -> ServerError+addBearerChallenge err =+ let headerName = "WWW-Authenticate"+ challengeHeader = (headerName, BS8.pack "Bearer realm=\"oauth\"")+ filteredHeaders = filter ((/= headerName) . fst) (errHeaders err)+ in err{errHeaders = challengeHeader : filteredHeaders}++exceptEither :: Either ServerError a -> ExceptT ServerError IO a+exceptEither = either throwE pure++liftEitherIO :: Either ServerError a -> Handler a+liftEitherIO = either throwError pure
+ src/Web/OAuth2/TokenAPI.hs view
@@ -0,0 +1,383 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeOperators #-}++-- |+-- Module: Web.OAuth2.TokenAPI+-- Copyright: (c) DPella AB 2025+-- License: MPL-2.0+-- Maintainer: <matti@dpella.io>, <lobo@dpella.io>+--+-- OAuth 2.1 Token Endpoint.+--+-- This module implements the OAuth 2.1 token endpoint that exchanges+-- authorization codes for access tokens and handles refresh token requests.+--+-- The implementation supports:+-- * Authorization code grant with PKCE verification+-- * Refresh token grant for obtaining new access tokens+-- * JWT-based access tokens signed by the server+--+-- All tokens are validated against the registered client information+-- and PKCE challenges when applicable.+module Web.OAuth2.TokenAPI where++import Control.Concurrent.MVar+import Control.Monad.IO.Class (liftIO)+import Crypto.Hash (Digest, SHA256 (..), hashWith)+import Data.Aeson+import Data.ByteArray qualified as BA+import Data.ByteString qualified as BS+import Data.ByteString.Base64.URL qualified as B64URL+import Data.ByteString.Lazy.Char8 qualified as BSL+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Data.Time.Clock+import GHC.Generics+import Web.OAuth2.Types+import Servant+import Servant.Auth.Server+import Web.FormUrlEncoded (FromForm (..))+import Prelude hiding (error)+import Data.ByteString.Char8 qualified as BS8++-- | Servant API type for the OAuth token endpoint.+--+-- Accepts form-encoded token requests and returns JSON responses+-- containing access tokens and optional refresh tokens.+type TokenAPI =+ "token"+ :> ReqBody '[FormUrlEncoded] TokenRequest+ :> Post+ '[JSON]+ (Headers '[Header "Cache-Control" Text, Header "Pragma" Text] TokenResponse)++-- | Token request parameters as defined in RFC 6749.+--+-- Supports both authorization code and refresh token grants.+-- The required fields depend on the grant type being used.+data TokenRequest = TokenRequest+ { grant_type :: Text+ -- ^ The type of grant being requested ("authorization_code" or "refresh_token")+ , code :: Maybe Text+ -- ^ Authorization code (required for authorization_code grant)+ , refresh_token :: Maybe Text+ -- ^ Refresh token (required for refresh_token grant)+ , redirect_uri :: Maybe Text+ -- ^ Must match the redirect_uri used in authorization request+ , client_id :: Text+ -- ^ Client identifier+ , client_secret :: Maybe Text+ -- ^ Client secret (for confidential clients, client_secret_post)+ , code_verifier :: Maybe Text+ -- ^ PKCE code verifier (required if code_challenge was used)+ }+ deriving (Eq, Show, Generic)++instance FromForm TokenRequest++-- | Token response structure as defined in RFC 6749.+--+-- Contains the access token and associated metadata.+-- Refresh tokens are only included for authorization_code grants.+data TokenResponse = TokenResponse+ { access_token :: Text+ -- ^ The access token (JWT) that can be used to authenticate requests+ , token_type :: Text+ -- ^ Type of token (always "Bearer")+ , expires_in :: Int+ -- ^ Token lifetime in seconds (3600 = 1 hour)+ , refresh_token_resp :: Maybe Text+ -- ^ Refresh token for obtaining new access tokens+ , scope :: Maybe Text+ -- ^ Granted scope (may be less than requested)+ }+ deriving (Eq, Show, Generic)++instance ToJSON TokenResponse where+ toJSON TokenResponse{..} =+ object $+ [ "access_token" .= access_token+ , "token_type" .= token_type+ , "expires_in" .= expires_in+ ]+ <> ["refresh_token" .= rt | Just rt <- [refresh_token_resp]]+ <> ["scope" .= s | Just s <- [scope]]++type TokenResponseHeaders = Headers '[Header "Cache-Control" Text, Header "Pragma" Text] TokenResponse++-- | Handle OAuth token requests for both authorization code and refresh token grants.+--+-- For authorization code grant:+--+-- 1. Validates the authorization code exists and hasn't expired+-- 2. Verifies client_id and redirect_uri match the authorization request+-- 3. Validates PKCE code_verifier if code_challenge was used+-- 4. Issues a JWT access token and refresh token+-- 5. Deletes the used authorization code+--+-- For refresh token grant:+--+-- 1. Validates the refresh token exists+-- 2. Verifies client_id matches+-- 3. Issues a new JWT access token+--+-- Access tokens are JWTs signed by the server with 1-hour expiry.+-- Authorization codes expire after 10 minutes.+handleTokenRequest+ :: forall usr ctxt+ . (ToJWT usr, HasContextEntry ctxt JWTSettings)+ => MVar (OAuthState usr)+ -> Context ctxt+ -> TokenRequest+ -> Handler TokenResponseHeaders+handleTokenRequest state_var ctxt TokenRequest{..} = do+ let jwtCfg = getContextEntry ctxt+ result <-+ liftIO $+ modifyMVar state_var $ \state ->+ processRequest jwtCfg state+ case result of+ Left err -> throwError (attachNoStoreError err)+ Right resp -> pure (attachNoStoreHeaders resp)+ where+ attachNoStoreHeaders :: TokenResponse -> TokenResponseHeaders+ attachNoStoreHeaders resp =+ let withPragma :: Headers '[Header "Pragma" Text] TokenResponse+ withPragma = addHeader ("no-cache" :: Text) resp+ in addHeader ("no-store" :: Text) withPragma++ attachNoStoreError :: ServerError -> ServerError+ attachNoStoreError err =+ let filtered =+ filter+ ( \ (name, _) ->+ name /= "Cache-Control" && name /= "Pragma"+ )+ (errHeaders err)+ in err{errHeaders = ("Cache-Control", "no-store") : ("Pragma", "no-cache") : filtered}++ processRequest+ :: JWTSettings+ -> OAuthState usr+ -> IO (OAuthState usr, Either ServerError TokenResponse)+ processRequest jwtCfg state =+ case Map.lookup client_id (registered_clients state) of+ Nothing ->+ pure (state, Left $ tokenAuthFailure "unauthorized_client" "Client not registered")+ Just client@RegisteredClient{} -> do+ authCheck <- authenticateClient client+ case authCheck of+ Left err -> pure (state, Left err)+ Right () -> processGrant jwtCfg state client++ authenticateClient :: RegisteredClient -> IO (Either ServerError ())+ authenticateClient RegisteredClient{..}+ | registered_client_token_endpoint_auth_method == "client_secret_post" =+ case (client_secret, registered_client_secret) of+ (Just provided, Just expected)+ | constTimeEq provided expected -> pure (Right ())+ | otherwise -> pure (Left $ tokenAuthFailure "invalid_client" "Invalid client secret")+ _ -> pure (Left $ tokenAuthFailure "invalid_client" "Missing client secret")+ | registered_client_token_endpoint_auth_method == "none" =+ pure (Right ())+ | otherwise =+ pure (Left $ tokenAuthFailure "invalid_client" "Unsupported client authentication method")++ processGrant+ :: JWTSettings+ -> OAuthState usr+ -> RegisteredClient+ -> IO (OAuthState usr, Either ServerError TokenResponse)+ processGrant jwtCfg state client@RegisteredClient{..}+ | grant_type `elem` registered_client_grant_types =+ case grant_type of+ "authorization_code" -> processAuthorizationCode jwtCfg state client+ "refresh_token" -> processRefreshTokenGrant jwtCfg state client+ _ -> pure (state, Left $ badTokenRequest "unsupported_grant_type" "Grant type not supported")+ | otherwise =+ pure (state, Left $ badTokenRequest "unauthorized_client" "Grant type not allowed for this client")++ processAuthorizationCode+ :: JWTSettings+ -> OAuthState usr+ -> RegisteredClient+ -> IO (OAuthState usr, Either ServerError TokenResponse)+ processAuthorizationCode jwtCfg state RegisteredClient{..} = do+ currentTime <- getCurrentTime+ case code of+ Nothing ->+ pure (state, Left $ badTokenRequest "invalid_request" "Missing authorization code")+ Just authCodeValue ->+ case Map.lookup authCodeValue (auth_codes state) of+ Nothing ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Invalid authorization code")+ Just AuthCode{..}+ | currentTime > auth_code_expiry ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Authorization code expired")+ | auth_code_client_id /= client_id ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Client ID mismatch")+ | otherwise ->+ case redirect_uri of+ Nothing ->+ pure (state, Left $ badTokenRequest "invalid_request" "Missing redirect_uri")+ Just ru+ | ru `notElem` registered_client_redirect_uris ->+ pure (state, Left $ badTokenRequest "invalid_grant" "redirect_uri not registered for client")+ | auth_code_redirect_uri /= ru ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Redirect URI mismatch")+ | otherwise ->+ let allowed_scopes = T.words registered_client_scope+ granted_scopes = T.words auth_code_scope+ in if not (all (`elem` allowed_scopes) granted_scopes)+ then pure (state, Left $ badTokenRequest "invalid_scope" "Invalid or excessive scope requested")+ else+ case (auth_code_challenge, auth_code_challenge_method, code_verifier) of+ (Just challenge, method, Just verifier)+ | verifyCodeChallenge challenge method verifier -> do+ accessTokenResult <- issueAccessToken jwtCfg auth_code_user+ case accessTokenResult of+ Left err -> pure (state, Left err)+ Right accessToken -> do+ let refreshAllowed = "refresh_token" `elem` registered_client_grant_types+ persistence = refresh_token_persistence state+ cleanedState =+ state+ { auth_codes = Map.delete authCodeValue (auth_codes state)+ }+ if refreshAllowed+ then do+ newRefreshToken <- generateToken+ let refreshRecord =+ RefreshToken+ { refresh_token_value = newRefreshToken+ , refresh_token_client_id = client_id+ , refresh_token_user = auth_code_user+ , refresh_token_scope = auth_code_scope+ }+ persistRefreshToken persistence refreshRecord+ pure+ ( cleanedState+ , Right+ TokenResponse+ { access_token = accessToken+ , token_type = "Bearer"+ , expires_in = 3600+ , refresh_token_resp = Just newRefreshToken+ , scope = Just auth_code_scope+ }+ )+ else+ pure+ ( cleanedState+ , Right+ TokenResponse+ { access_token = accessToken+ , token_type = "Bearer"+ , expires_in = 3600+ , refresh_token_resp = Nothing+ , scope = Just auth_code_scope+ }+ )+ | otherwise ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Invalid code verifier")+ _ ->+ pure (state, Left $ badTokenRequest "invalid_request" "PKCE required: missing code_challenge or code_verifier")++ processRefreshTokenGrant+ :: JWTSettings+ -> OAuthState usr+ -> RegisteredClient+ -> IO (OAuthState usr, Either ServerError TokenResponse)+ processRefreshTokenGrant jwtCfg state RegisteredClient{..} =+ case refresh_token of+ Nothing ->+ pure (state, Left $ badTokenRequest "invalid_request" "Missing refresh token")+ Just rtValue -> do+ let persistence = refresh_token_persistence state+ stored <- lookupRefreshToken persistence rtValue+ case stored of+ Nothing ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Invalid refresh token")+ Just RefreshToken{..}+ | refresh_token_client_id /= client_id ->+ pure (state, Left $ badTokenRequest "invalid_grant" "Client ID mismatch")+ | otherwise ->+ let allowed_scopes = T.words registered_client_scope+ granted_scopes = T.words refresh_token_scope+ in if not (all (`elem` allowed_scopes) granted_scopes)+ then pure (state, Left $ badTokenRequest "invalid_scope" "Invalid or excessive scope requested")+ else do+ accessTokenResult <- issueAccessToken jwtCfg refresh_token_user+ case accessTokenResult of+ Left err -> pure (state, Left err)+ Right accessToken -> do+ newRefreshToken <- generateToken+ let newToken =+ RefreshToken+ { refresh_token_value = newRefreshToken+ , refresh_token_client_id = client_id+ , refresh_token_user = refresh_token_user+ , refresh_token_scope = refresh_token_scope+ }+ deleteRefreshToken persistence rtValue+ persistRefreshToken persistence newToken+ pure+ ( state+ , Right+ TokenResponse+ { access_token = accessToken+ , token_type = "Bearer"+ , expires_in = 3600+ , refresh_token_resp = Just newRefreshToken+ , scope = Just refresh_token_scope+ }+ )++ issueAccessToken :: JWTSettings -> usr -> IO (Either ServerError Text)+ issueAccessToken jwtCfg user = do+ now <- getCurrentTime+ jwtRes <- makeJWT user jwtCfg $ Just (addUTCTime 3600 now)+ pure $+ case jwtRes of+ Left _ -> Left $ internalServerError "Failed to sign access token"+ Right token -> Right $ T.pack $ BSL.unpack token++ verifyCodeChallenge :: Text -> Maybe Text -> Text -> Bool+ verifyCodeChallenge challenge method verifier =+ case method of+ Just "S256" ->+ let verifier_bs = T.encodeUtf8 verifier+ hash = hashWith SHA256 verifier_bs :: Digest SHA256+ hash_bs = BS.pack $ BA.unpack hash+ encoded = T.decodeUtf8 $ B64URL.encodeUnpadded hash_bs+ in encoded == challenge+ Just "plain" -> challenge == verifier+ Nothing -> challenge == verifier+ _ -> False++ badTokenRequest :: Text -> Text -> ServerError+ badTokenRequest error_code error_description =+ oauthErrorResponse err400 error_code (Just error_description)++ tokenAuthFailure :: Text -> Text -> ServerError+ tokenAuthFailure error_code error_description =+ addAuthChallenge $+ oauthErrorResponse err401 error_code (Just error_description)++ internalServerError :: Text -> ServerError+ internalServerError message =+ oauthErrorResponse err500 "server_error" (Just message)++ addAuthChallenge :: ServerError -> ServerError+ addAuthChallenge err =+ let headerName = "WWW-Authenticate"+ challengeHeader = (headerName, BS8.pack "Basic realm=\"oauth\"")+ filteredHeaders = filter ((/= headerName) . fst) (errHeaders err)+ in err{errHeaders = challengeHeader : filteredHeaders}
+ src/Web/OAuth2/Types.hs view
@@ -0,0 +1,318 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE TypeFamilies #-}++-- |+-- Module: Web.OAuth2.Types+-- Copyright: (c) DPella AB 2025+-- License: MPL-2.0+-- Maintainer: <matti@dpella.io>, <lobo@dpella.io>+--+-- Core types and data structures for the OAuth 2.1 implementation.+--+-- This module defines the fundamental types used throughout the OAuth system:+-- * Authorization codes with expiry and PKCE parameters+-- * Refresh tokens for long-lived access+-- * Client registrations with allowed grants and scopes+-- * Server state management+--+-- The types support the OAuth 2.1 authorization code flow with PKCE+-- as defined in RFC 6749 and RFC 7636.+module Web.OAuth2.Types where++import Control.Concurrent.MVar (modifyMVar_, newMVar, readMVar)+import Data.Aeson (defaultOptions, encode, omitNothingFields)+import Data.Aeson.TH (deriveJSON)+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.HTTP.Types (hContentType)+import Network.URI+import Servant (Context, HasContextEntry)+import Servant.Auth.Server (AuthResult (..))+import Text.Blaze.Html5 (Html)+import Servant.Server (ServerError (..))+import Crypto.Random (getRandomBytes)+import Data.ByteArray qualified as BA+import Data.ByteString.Base64.URL qualified as B64URL++-- | Authorization code issued after successful authentication.+--+-- Authorization codes are short-lived (10 minutes) and single-use.+-- They must be exchanged for access tokens at the token endpoint.+data AuthCode usr = AuthCode+ { auth_code_value :: Text+ -- ^ The authorization code value+ , auth_code_client_id :: Text+ -- ^ Client that requested this code+ , auth_code_user :: usr+ -- ^ Authenticated user+ , auth_code_redirect_uri :: Text+ -- ^ Redirect URI that must match token request+ , auth_code_scope :: Text+ -- ^ Granted scope+ , auth_code_expiry :: UTCTime+ -- ^ When this code expires+ , auth_code_challenge :: Maybe Text+ -- ^ PKCE code challenge+ , auth_code_challenge_method :: Maybe Text+ -- ^ PKCE challenge method (S256 or plain)+ }++-- | Refresh token for obtaining new access tokens.+--+-- Refresh tokens are long-lived and can be used multiple times+-- to obtain new access tokens when the current one expires.+data RefreshToken usr = RefreshToken+ { refresh_token_value :: Text+ -- ^ The refresh token value+ , refresh_token_client_id :: Text+ -- ^ Client that owns this token+ , refresh_token_user :: usr+ -- ^ User associated with this token+ , refresh_token_scope :: Text+ -- ^ Maximum scope for new access tokens+ }++-- | Persistence callbacks for refresh tokens.+data RefreshTokenPersistence usr = RefreshTokenPersistence+ { persistRefreshToken :: RefreshToken usr -> IO ()+ -- ^ Persist a refresh token+ , deleteRefreshToken :: Text -> IO ()+ -- ^ Delete a persisted refresh token by its value+ , lookupRefreshToken :: Text -> IO (Maybe (RefreshToken usr))+ -- ^ Lookup a refresh token+ }++-- | Default Map-backed refresh token persistence implementation.+mkDefaultRefreshTokenPersistence :: IO (RefreshTokenPersistence usr)+mkDefaultRefreshTokenPersistence = do+ rt_st <- newMVar Map.empty+ pure+ RefreshTokenPersistence+ { persistRefreshToken = \rt -> modifyMVar_ rt_st $ \mp ->+ pure (Map.insert (refresh_token_value rt) rt mp)+ , deleteRefreshToken = \tok -> modifyMVar_ rt_st $ \mp ->+ pure (Map.delete tok mp)+ , lookupRefreshToken = \tok ->+ readMVar rt_st >>= \mp ->+ pure (Map.lookup tok mp)+ }++-- | OAuth client registration information.+--+-- Contains all metadata about a registered OAuth client including+-- allowed redirect URIs, grant types, and maximum requestable scope.+data RegisteredClient = RegisteredClient+ { registered_client_id :: Text+ -- ^ Unique registered_client identifier+ , registered_client_name :: Text+ -- ^ Human-readable registered_client name+ , registered_client_secret :: Maybe Text+ -- ^ Secret for confidential registered_clients (Nothing for public registered_clients)+ , registered_client_redirect_uris :: [Text]+ -- ^ Allowed redirect URIs+ , registered_client_grant_types :: [Text]+ -- ^ Allowed OAuth grant types+ , registered_client_response_types :: [Text]+ -- ^ Allowed OAuth response types+ , registered_client_scope :: Text+ -- ^ Maximum scope this registered_client can request+ , registered_client_token_endpoint_auth_method :: Text+ -- ^ Required auth method at token endpoint+ , registered_client_registration_access_token :: Maybe Text+ -- ^ Registration access token issued for dynamic client management+ }+ deriving (Eq, Show)++-- | Parameters passed to the login form renderer.+--+-- Custom renderers must produce an HTML form that POSTs to+-- @authorize\/callback@ with at least the following fields:+--+-- * @username@ — the user's login name+-- * @password@ — the user's password+-- * @client_id@ — copied from 'lfp_client_id'+-- * @redirect_uri@ — copied from 'lfp_redirect_uri'+-- * @scope@ — copied from 'lfp_scope'+-- * @state@ — copied from 'lfp_state' when present+-- * @code_challenge@ — copied from 'lfp_code_challenge' when present+-- * @code_challenge_method@ — copied from 'lfp_code_challenge_method' when present+data LoginFormParams = LoginFormParams+ { lfp_client_id :: Text+ -- ^ OAuth client identifier+ , lfp_redirect_uri :: Text+ -- ^ Client's registered redirect URI+ , lfp_scope :: Text+ -- ^ Space-delimited requested scopes+ , lfp_state :: Maybe Text+ -- ^ Client's opaque state parameter+ , lfp_code_challenge :: Maybe Text+ -- ^ PKCE code challenge+ , lfp_code_challenge_method :: Maybe Text+ -- ^ PKCE challenge method (@S256@ or @plain@)+ , lfp_error :: Maybe Text+ -- ^ Error from a previous login attempt (e.g. @invalid_password@)+ }++-- | Global state for the OAuth authorization server.+--+-- Maintains all active authorization codes, refresh tokens,+-- and registered clients. This state is shared across all+-- OAuth endpoints via an MVar.+data OAuthState usr = OAuthState+ { auth_codes :: Map.Map Text (AuthCode usr)+ -- ^ Active authorization codes indexed by code value+ , refresh_token_persistence :: RefreshTokenPersistence usr+ -- ^ Persistence layer for refresh tokens+ , registered_clients :: Map.Map Text RegisteredClient+ -- ^ Registered clients indexed by client_idQ+ , oauth_url :: Text+ -- ^ Base URL for the OAuth server+ , oauth_port :: Int+ -- ^ Port for the OAuth server+ , login_form_renderer :: LoginFormParams -> Html+ -- ^ Render the authorization login form. Use 'defaultLoginFormRenderer'+ -- from "Web.OAuth2.AuthorizeAPI" for the built-in page, or supply your+ -- own function.+ }++-- | Represents errors that can occur during the OAuth authentication process.+--+-- The 'OAuthError' type is used to capture and describe various error conditions+-- that may arise when handling OAuth flows, such as invalid credentials, expired tokens,+-- missing parameters, or network issues. Each constructor of this type provides+-- information about a specific kind of OAuth-related failure, which can be used+-- for error handling, logging, or user feedback.+data OAuthError = OAuthError+ { error :: Text+ , error_description :: Maybe Text+ , error_uri :: Maybe Text+ }+ deriving (Generic)++$( deriveJSON+ defaultOptions{omitNothingFields = True}+ ''OAuthError+ )++-- | Constructs an 'OAuthError' value with the given error message.+-- The resulting 'OAuthError' will have the provided error text,+-- and 'Nothing' for the optional description and URI fields.+oAuthError :: Text -> OAuthError+oAuthError err = OAuthError err Nothing Nothing++-- | Attach an OAuthError JSON payload to a Servant ServerError.+jsonErrorResponse :: ServerError -> OAuthError -> ServerError+jsonErrorResponse base oauthErr =+ let contentHeader = (hContentType, "application/json; charset=utf-8")+ filteredHeaders = filter ((/= hContentType) . fst) (errHeaders base)+ in base+ { errBody = encode oauthErr+ , errHeaders = contentHeader : filteredHeaders+ }++-- | Convenience helper to build a JSON OAuth error response.+oauthErrorResponse :: ServerError -> Text -> Maybe Text -> ServerError+oauthErrorResponse base code description =+ jsonErrorResponse base (oAuthError code){error_description = description}++-- | Initialize an empty OAuth server state.+--+-- Creates a new OAuth state with no registered clients,+-- authorization codes, or refresh tokens.+initOAuthState+ :: forall usr+ . Text+ -> Int+ -> RefreshTokenPersistence usr+ -> (LoginFormParams -> Html)+ -> OAuthState usr+initOAuthState url port rtp renderer =+ OAuthState+ { auth_codes = Map.empty+ , refresh_token_persistence = rtp+ , registered_clients = Map.empty+ , oauth_url = url+ , oauth_port = port+ , login_form_renderer = renderer+ }++-- | Type alias for token values (authorization codes, refresh tokens, client IDs).+type Token = Text++-- | Generate a cryptographically secure random token.+--+-- Produces a Base64URL-encoded token derived from 32 bytes of+-- cryptographic entropy. The output is URL-safe and suitable for+-- use as authorization codes, refresh tokens, or client identifiers.+generateToken :: IO Token+generateToken = do+ bytes <- getRandomBytes 32+ pure $ TE.decodeUtf8 (B64URL.encodeUnpadded bytes)++-- | Normalize the configured base URL ensuring the desired port is present.+normalizeBaseUrl :: Text -> Int -> Text+normalizeBaseUrl rawUrl port =+ case parseURI (T.unpack rawUrl) of+ Just uri ->+ case uriAuthority uri of+ Just auth ->+ let hasPort = not (null (uriPort auth))+ desiredPort = if port > 0 then ":" <> show port else ""+ authWithPort =+ if hasPort || null desiredPort+ then auth+ else auth{uriPort = desiredPort}+ normalizedUri = uri{uriAuthority = Just authWithPort}+ in T.pack (uriToString id normalizedUri "")+ Nothing -> appendPortFallback rawUrl port+ Nothing -> appendPortFallback rawUrl port+ where+ appendPortFallback :: Text -> Int -> Text+ appendPortFallback url port' =+ let portTxt = ":" <> T.pack (show port')+ in if port' <= 0 || portTxt `T.isInfixOf` url+ then url+ else+ let (prefix, suffix) = T.breakOn "/" url+ in if T.null suffix+ then url <> portTxt+ else prefix <> portTxt <> suffix++-- | Remove any trailing '/' from a URL.+stripTrailingSlash :: Text -> Text+stripTrailingSlash = T.dropWhileEnd (== '/')++-- | Append a path segment to a base URL, preserving fragments.+appendPathSegment :: Text -> Text -> Text+appendPathSegment base segment =+ let (baseWithoutFragment, fragmentPart) = T.breakOn "#" base+ baseStripped =+ if T.null baseWithoutFragment+ then baseWithoutFragment+ else T.dropWhileEnd (== '/') baseWithoutFragment+ combined = baseStripped <> segment+ in combined <> fragmentPart++-- | Constant-time equality comparison for Text values.+--+-- Prevents timing side-channel attacks when comparing secrets,+-- tokens, or other security-sensitive strings.+constTimeEq :: Text -> Text -> Bool+constTimeEq a b = BA.constEq (TE.encodeUtf8 a) (TE.encodeUtf8 b)++-- | Class for verifying user credentials from username and password+class FormAuth usr where+ type FormAuthSettings usr+ runFormAuth+ :: (HasContextEntry ctxt (FormAuthSettings usr))+ => Context ctxt+ -> Text+ -> Text+ -> IO (AuthResult usr)
+ test/Main.hs view
@@ -0,0 +1,24 @@+module Main where++import Test.Tasty+import qualified Web.OAuth2.AuthorizeCallbackSpec+import qualified Web.OAuth2.AuthorizeSpec+import qualified Web.OAuth2.FlowSpec+import qualified Web.OAuth2.MetadataSpec+import qualified Web.OAuth2.RegisterSpec+import qualified Web.OAuth2.TokenSpec++main :: IO ()+main = defaultMain tests++tests :: TestTree+tests =+ testGroup+ "OAuth Tests"+ [ Web.OAuth2.MetadataSpec.tests+ , Web.OAuth2.RegisterSpec.tests+ , Web.OAuth2.AuthorizeSpec.tests+ , Web.OAuth2.AuthorizeCallbackSpec.tests+ , Web.OAuth2.TokenSpec.tests+ , Web.OAuth2.FlowSpec.tests+ ]
+ test/Web/OAuth2/AuthorizeCallbackSpec.hs view
@@ -0,0 +1,241 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}++module Web.OAuth2.AuthorizeCallbackSpec (tests) where++import Control.Concurrent.MVar (MVar, readMVar)+import Data.Aeson (eitherDecode)+import Data.ByteString.Lazy qualified as LBS+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Time.Clock (addUTCTime, getCurrentTime)+import Network.HTTP.Types (hContentType, hLocation, methodPost, status303, status400)+import Network.Wai (Application, requestHeaders, requestMethod)+import Network.Wai.Test+import Test.Tasty+import Test.Tasty.HUnit+import Web.OAuth2.TestUtils+import Web.OAuth2.Types++tests :: TestTree+tests =+ testGroup+ "Authorize callback endpoint"+ [ successfulLoginIssuesAuthCode+ , invalidCredentialsRedirectBack+ , rejectsTamperedRedirectUri+ , rejectsInvalidScope+ , rejectsMissingPkce+ , successfulLoginWithoutStateSkipsEcho+ ]++withApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a+withApp action = do+ (stateVar, _ctx, app) <- createTestApplication+ addRegisteredClientToState stateVar (mkPublicClient "client-1" ["http://localhost:4000/cb"] "read write")+ action stateVar app++successfulLoginIssuesAuthCode :: TestTree+successfulLoginIssuesAuthCode = testCase "stores auth code and redirects with state" $+ withApp $ \stateVar app -> do+ now <- getCurrentTime+ let challenge = "verifier123"+ formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "testpass")+ , ("client_id", "client-1")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("scope", "read")+ , ("state", "my-state")+ , ("code_challenge", challenge)+ , ("code_challenge_method", "plain")+ ]+ req =+ SRequest+ ( setPath defaultRequest "/authorize/callback"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- runSession (srequest req) app+ simpleStatus res @?= status303+ let location = lookup hLocation (simpleHeaders res)+ case location of+ Nothing -> assertFailure "Location header missing"+ Just loc -> do+ let locText = TE.decodeUtf8 loc+ assertBool "state propagated" ("state=my-state" `T.isInfixOf` locText)+ assertBool "authorization code parameter present" ("code=" `T.isInfixOf` locText)++ st <- readMVar stateVar+ let codes = Map.elems (auth_codes st)+ assertBool "auth code stored" (not (null codes))+ stored <- case codes of+ (x:_) -> pure x+ [] -> assertFailure "no auth codes stored"+ auth_code_client_id stored @?= "client-1"+ auth_code_scope stored @?= "read"+ auth_code_challenge stored @?= Just challenge+ auth_code_challenge_method stored @?= Just "plain"+ assertBool "expiry in future" (auth_code_expiry stored > addUTCTime (-1) now)++invalidCredentialsRedirectBack :: TestTree+invalidCredentialsRedirectBack = testCase "redirects back to authorize with error=invalid_password" $+ withApp $ \stateVar app -> do+ let formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "wrong")+ , ("client_id", "client-1")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("scope", "read write")+ , ("state", "orig-state")+ , ("code_challenge", "abc")+ , ("code_challenge_method", "S256")+ ]+ req =+ SRequest+ ( setPath defaultRequest "/authorize/callback"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- runSession (srequest req) app+ simpleStatus res @?= status303+ let location = lookup hLocation (simpleHeaders res)+ case location of+ Nothing -> assertFailure "Location header missing"+ Just loc -> do+ let locText = TE.decodeUtf8 loc+ assertBool "redirected to authorize" ("../authorize" `T.isPrefixOf` locText)+ assertBool "state preserved" ("state=orig-state" `T.isInfixOf` locText)+ assertBool "error flag propagated" ("error=invalid_password" `T.isInfixOf` locText)++ st <- readMVar stateVar+ Map.size (auth_codes st) @?= 0++rejectsTamperedRedirectUri :: TestTree+rejectsTamperedRedirectUri = testCase "rejects redirect_uri not registered for client" $+ withApp $ \stateVar app -> do+ let formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "testpass")+ , ("client_id", "client-1")+ , ("redirect_uri", "https://attacker.invalid/cb")+ , ("scope", "read")+ , ("state", "state-1")+ , ("code_challenge", "challenge")+ , ("code_challenge_method", "plain")+ ]+ req =+ SRequest+ ( setPath defaultRequest "/authorize/callback"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- runSession (srequest req) app+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ err <- decodeError (simpleBody res)+ Web.OAuth2.Types.error err @?= "unauthorized_client"+ st <- readMVar stateVar+ Map.size (auth_codes st) @?= 0++rejectsInvalidScope :: TestTree+rejectsInvalidScope = testCase "rejects scope escalation attempts" $+ withApp $ \stateVar app -> do+ let formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "testpass")+ , ("client_id", "client-1")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("scope", "admin")+ , ("state", "state-2")+ , ("code_challenge", "challenge")+ , ("code_challenge_method", "plain")+ ]+ req =+ SRequest+ ( setPath defaultRequest "/authorize/callback"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- runSession (srequest req) app+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ err <- decodeError (simpleBody res)+ Web.OAuth2.Types.error err @?= "invalid_scope"+ st <- readMVar stateVar+ Map.size (auth_codes st) @?= 0++rejectsMissingPkce :: TestTree+rejectsMissingPkce = testCase "rejects requests missing PKCE code_challenge" $+ withApp $ \stateVar app -> do+ let formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "testpass")+ , ("client_id", "client-1")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("scope", "read")+ , ("state", "state-3")+ ]+ req =+ SRequest+ ( setPath defaultRequest "/authorize/callback"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- runSession (srequest req) app+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ err <- decodeError (simpleBody res)+ Web.OAuth2.Types.error err @?= "invalid_request"+ st <- readMVar stateVar+ Map.size (auth_codes st) @?= 0++successfulLoginWithoutStateSkipsEcho :: TestTree+successfulLoginWithoutStateSkipsEcho = testCase "does not add state parameter when none provided" $+ withApp $ \stateVar app -> do+ let challenge = "verifier456"+ formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "testpass")+ , ("client_id", "client-1")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("scope", "read")+ , ("code_challenge", challenge)+ , ("code_challenge_method", "plain")+ ]+ req =+ SRequest+ ( setPath defaultRequest "/authorize/callback"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- runSession (srequest req) app+ simpleStatus res @?= status303+ let location = lookup hLocation (simpleHeaders res)+ case location of+ Nothing -> assertFailure "Location header missing"+ Just loc -> do+ let locText = TE.decodeUtf8 loc+ assertBool "redirected to provided redirect_uri" ("http://localhost:4000/cb" `T.isPrefixOf` locText)+ assertBool "state parameter absent" (not ("state=" `T.isInfixOf` locText))+ st <- readMVar stateVar+ Map.size (auth_codes st) @?= 1++decodeError :: LBS.ByteString -> IO OAuthError+decodeError body =+ case eitherDecode body of+ Left msg -> assertFailure ("Failed to decode OAuth error: " <> msg)+ Right v -> pure v
+ test/Web/OAuth2/AuthorizeSpec.hs view
@@ -0,0 +1,260 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module Web.OAuth2.AuthorizeSpec (tests) where++import Control.Concurrent.MVar (MVar, newMVar)+import Data.Aeson (eitherDecode)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Network.HTTP.Types (hContentType, status200, status303, status400, status401)+import Network.HTTP.Types.URI (renderQuery)+import Network.Wai (Application)+import Network.Wai.Test+import Servant (Proxy (..), serveWithContext)+import Test.Tasty+import Test.Tasty.HUnit+import Text.Blaze.Html5 qualified as H+import Web.OAuth2 (OAuthAPI, oAuthAPI)+import Web.OAuth2.Internal (validateScope)+import Web.OAuth2.TestUtils+import Web.OAuth2.Types hiding (error)+import Web.OAuth2.Types qualified as OAuthTypes++tests :: TestTree+tests =+ testGroup+ "Authorize endpoint"+ [ rejectsUnknownClient+ , rejectsMismatchedRedirect+ , rejectsInvalidScope+ , rendersLoginFormWithPkce+ , echoesErrorMessage+ , omitsStateHiddenFieldWhenAbsent+ , missingParametersReturnInvalidRequest+ , rejectsUnsupportedResponseType+ , usesCustomLoginFormRenderer+ , validateScopeTests+ ]++withApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a+withApp action = do+ (stateVar, _ctx, app) <- createTestApplication+ action stateVar app++reconstructError :: LBS.ByteString -> IO OAuthError+reconstructError body =+ case eitherDecode body of+ Left err -> assertFailure ("Failed to decode OAuthError: " <> err)+ Right val -> pure val++rejectsUnknownClient :: TestTree+rejectsUnknownClient = testCase "returns 401 for unknown client_id" $+ withApp $ \_ app -> do+ let query =+ [ ("response_type", Just "code")+ , ("client_id", Just "missing-client")+ , ("redirect_uri", Just "http://localhost:4000/cb")+ , ("scope", Just "read")+ , ("state", Just "state-1")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status401+ errResp <- reconstructError (simpleBody res)+ OAuthTypes.error errResp @?= "unauthorized_client"++rejectsMismatchedRedirect :: TestTree+rejectsMismatchedRedirect = testCase "400 when redirect_uri not registered" $+ withApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "cid-1" ["http://localhost:4000/callback"] "read write")+ let query =+ [ ("response_type", Just "code")+ , ("client_id", Just "cid-1")+ , ("redirect_uri", Just "http://localhost:4000/evil")+ , ("scope", Just "read")+ , ("state", Just "s")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status400+ errResp <- reconstructError (simpleBody res)+ OAuthTypes.error errResp @?= "unauthorized_client"++rejectsInvalidScope :: TestTree+rejectsInvalidScope = testCase "redirects with invalid_scope when request exceeds client allow list" $+ withApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "cid-2" ["http://localhost:4000/callback"] "read write")+ let query =+ [ ("response_type", Just "code")+ , ("client_id", Just "cid-2")+ , ("redirect_uri", Just "http://localhost:4000/callback")+ , ("scope", Just "admin")+ , ("state", Just "s")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status303+ case lookup "Location" (simpleHeaders res) of+ Nothing -> assertFailure "Location header missing on invalid scope redirect"+ Just loc -> do+ let locText = TE.decodeUtf8 loc+ assertBool "redirect URI preserved" ("http://localhost:4000/callback" `T.isPrefixOf` locText)+ assertBool "invalid_scope error included" ("error=invalid_scope" `T.isInfixOf` locText)+ assertBool "state propagated" ("state=s" `T.isInfixOf` locText)++omitsStateHiddenFieldWhenAbsent :: TestTree+omitsStateHiddenFieldWhenAbsent = testCase "does not propagate state when request omitted it" $+ withApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "cid-5" ["http://localhost:4000/cb"] "read")+ let query =+ [ ("response_type", Just "code")+ , ("client_id", Just "cid-5")+ , ("redirect_uri", Just "http://localhost:4000/cb")+ , ("scope", Just "read")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status200+ let bodyTxt = LBS.toStrict (simpleBody res)+ assertBool "state input absent" (not ("name=\"state\"" `BS.isInfixOf` bodyTxt))++missingParametersReturnInvalidRequest :: TestTree+missingParametersReturnInvalidRequest = testCase "returns JSON invalid_request when required params absent" $+ withApp $ \_ app -> do+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest "/authorize") LBS.empty))+ app+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ errResp <- reconstructError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_request"++rendersLoginFormWithPkce :: TestTree+rendersLoginFormWithPkce = testCase "renders login form for valid request including PKCE fields" $+ withApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "cid-3" ["http://localhost:4000/cb"] "read write")+ let query =+ [ ("response_type", Just "code")+ , ("client_id", Just "cid-3")+ , ("redirect_uri", Just "http://localhost:4000/cb")+ , ("scope", Just "read write")+ , ("state", Just "xyz")+ , ("code_challenge", Just "pkce-challenge")+ , ("code_challenge_method", Just "S256")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status200+ let bodyTxt = LBS.toStrict (simpleBody res)+ assertBool "code_challenge field present" ("name=\"code_challenge\"" `BS.isInfixOf` bodyTxt)+ assertBool "code_challenge_method field present" ("name=\"code_challenge_method\"" `BS.isInfixOf` bodyTxt)+ assertBool "state preserved" ("value=\"xyz\"" `BS.isInfixOf` bodyTxt)++echoesErrorMessage :: TestTree+echoesErrorMessage = testCase "renders login form with error message when provided" $+ withApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "cid-4" ["http://localhost:4000/cb"] "read")+ let query =+ [ ("response_type", Just "code")+ , ("client_id", Just "cid-4")+ , ("redirect_uri", Just "http://localhost:4000/cb")+ , ("scope", Just "read")+ , ("state", Just "s")+ , ("error", Just "invalid_password")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status200+ let bodyTxt = LBS.toStrict (simpleBody res)+ assertBool "error message rendered" ("Invalid username or password" `BS.isInfixOf` bodyTxt)++rejectsUnsupportedResponseType :: TestTree+rejectsUnsupportedResponseType = testCase "rejects response_type=token with unsupported_response_type" $+ withApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "cid-6" ["http://localhost:4000/cb"] "read")+ let query =+ [ ("response_type", Just "token")+ , ("client_id", Just "cid-6")+ , ("redirect_uri", Just "http://localhost:4000/cb")+ , ("scope", Just "read")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status400+ errResp <- reconstructError (simpleBody res)+ OAuthTypes.error errResp @?= "unsupported_response_type"++usesCustomLoginFormRenderer :: TestTree+usesCustomLoginFormRenderer = testCase "authorize endpoint invokes custom login_form_renderer" $ do+ let customRenderer _params = H.unsafeByteString "CUSTOM_FORM"+ persistence <- mkDefaultRefreshTokenPersistence+ stateVar <- newMVar (initOAuthState @TestUser "http://localhost:8080" 8080 persistence customRenderer)+ addRegisteredClientToState stateVar (mkPublicClient "cid-custom" ["http://localhost:4000/cb"] "read")+ ctx <- createTestContext+ let app = serveWithContext (Proxy :: Proxy OAuthAPI) ctx (oAuthAPI stateVar ctx)+ query =+ [ ("response_type", Just "code")+ , ("client_id", Just "cid-custom")+ , ("redirect_uri", Just "http://localhost:4000/cb")+ , ("scope", Just "read")+ , ("state", Just "xyz")+ , ("code_challenge", Just "test-challenge")+ , ("code_challenge_method", Just "S256")+ ]+ path = BS.concat ["/authorize", renderQuery True query]+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest path) LBS.empty))+ app+ simpleStatus res @?= status200+ let bodyTxt = LBS.toStrict (simpleBody res)+ assertBool "custom renderer output present" ("CUSTOM_FORM" `BS.isInfixOf` bodyTxt)+ assertBool "default form not rendered" (not ("Sign In" `BS.isInfixOf` bodyTxt))++validateScopeTests :: TestTree+validateScopeTests =+ testGroup+ "validateScope"+ [ testCase "single scope within allowed" $+ validateScope "read" "read write" @?= True+ , testCase "multiple scopes within allowed" $+ validateScope "read write" "read write admin" @?= True+ , testCase "exact match" $+ validateScope "read write" "read write" @?= True+ , testCase "rejects scope not in allowed list" $+ validateScope "admin" "read write" @?= False+ , testCase "rejects partially invalid scope" $+ validateScope "read admin" "read write" @?= False+ , testCase "rejects empty requested scope" $+ validateScope "" "read write" @?= False+ , testCase "duplicate requested scopes still valid" $+ validateScope "read read" "read write" @?= True+ , testCase "extra whitespace handled" $+ validateScope "read write" "read write" @?= True+ ]
+ test/Web/OAuth2/FlowSpec.hs view
@@ -0,0 +1,275 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module Web.OAuth2.FlowSpec (tests) where++import Control.Concurrent.MVar+import Control.Monad.IO.Class (MonadIO, liftIO)+import Crypto.Hash (Digest, SHA256 (..), hashWith)+import Data.Aeson (Value (..), eitherDecode, object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteArray qualified as BA+import Data.ByteString qualified as BS+import Data.ByteString.Base64.URL qualified as B64URL+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.List (find)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromJust, isJust)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Time.Clock+import Network.HTTP.Types (hContentType, hLocation, methodPost, status200, status201, status303)+import Network.Wai (requestHeaders, requestMethod)+import Network.Wai.Test+import Servant+import Test.Tasty+import Test.Tasty.HUnit+import Web.OAuth2 (OAuthAPI, oAuthAPI)+import Web.OAuth2.TestUtils+import Web.OAuth2.Types++tests :: TestTree+tests = testGroup "OAuth End-to-End Flow" [commonFlow]++commonFlow :: TestTree+commonFlow = testCase "Dynamic registration -> authorize -> token -> refresh" $ do+ stateVar <- emptyOAuthState+ ctx <- createTestContext+ let app :: Application+ app = serveWithContext (Proxy :: Proxy OAuthAPI) ctx (oAuthAPI stateVar ctx)++ metadataValue <-+ runSession+ ( do+ let req = setPath defaultRequest "/.well-known/oauth-authorization-server"+ res <- srequest (SRequest req LBS.empty)+ liftIO $ assertEqual "metadata status" status200 (simpleStatus res)+ case eitherDecode (simpleBody res) :: Either String Value of+ Left e -> abort e+ Right v -> pure v+ )+ app+ metadataObj <-+ case metadataValue of+ Object o -> pure o+ _ -> abort "unexpected metadata response"+ issuerValue <- lookupText "issuer" metadataObj+ authorizeEndpointValue <- lookupText "authorization_endpoint" metadataObj+ tokenEndpointValue <- lookupText "token_endpoint" metadataObj+ registerEndpointValue <- lookupText "registration_endpoint" metadataObj+ issuerValue @?= "http://localhost:8080"+ authorizeEndpointValue @?= "http://localhost:8080/authorize"+ tokenEndpointValue @?= "http://localhost:8080/token"+ registerEndpointValue @?= "http://localhost:8080/register"+ case KM.lookup "token_endpoint_auth_methods_supported" metadataObj of+ Just (Array arr) -> toList arr @?= [String "none", String "client_secret_post"]+ _ -> abort "token_endpoint_auth_methods_supported missing"+ case KM.lookup "scopes_supported" metadataObj of+ Just (Array arr) -> toList arr @?= [String "read", String "write"]+ _ -> abort "scopes_supported missing"++ let redirectUri = "http://localhost:4000/cb"+ regResponsePublic <-+ runSession+ ( do+ let body =+ object+ [ "client_name" .= ("Test SPA" :: Text)+ , "redirect_uris" .= [redirectUri]+ , "grant_types" .= (["authorization_code", "refresh_token"] :: [Text])+ , "response_types" .= (["code"] :: [Text])+ , "scope" .= ("read write" :: Text)+ , "token_endpoint_auth_method" .= ("none" :: Text)+ ]+ req =+ SRequest+ (setPath defaultRequest "/register")+ { requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/json")]+ }+ (Aeson.encode body)+ res <- srequest req+ liftIO $ assertEqual "register status" status201 (simpleStatus res)+ case eitherDecode (simpleBody res) :: Either String Value of+ Left e -> abort e+ Right v -> pure v+ )+ app+ publicObj <- valueToObject regResponsePublic+ clientId <- lookupTextFromObject "client_id" publicObj+ registrationToken <- lookupTextFromObject "registration_access_token" publicObj+ registrationUri <- lookupTextFromObject "registration_client_uri" publicObj+ assertBool "public client secret omitted" (KM.lookup "client_secret" publicObj == Nothing)+ assertBool "public client secret expiry omitted" (KM.lookup "client_secret_expires_at" publicObj == Nothing)+ assertBool "registration access token present" (not (T.null registrationToken))+ assertBool "registration client URI includes register path" ("/register/" `T.isInfixOf` registrationUri)++ st1 <- readMVar stateVar+ case Map.lookup clientId (registered_clients st1) of+ Nothing -> assertFailure "client not inserted in state"+ Just c -> do+ registered_client_token_endpoint_auth_method c @?= "none"+ registered_client_grant_types c @?= ["authorization_code", "refresh_token"]+ registered_client_registration_access_token c @?= Just registrationToken++ now <- getCurrentTime+ verifier <- pure "verifier-value"+ let challenge = base64UrlHash verifier+ authorizeQuery =+ encodeForm+ [ ("response_type", "code")+ , ("client_id", clientId)+ , ("redirect_uri", redirectUri)+ , ("scope", "read")+ , ("state", "state-xyz")+ , ("code_challenge", challenge)+ , ("code_challenge_method", "S256")+ ]+ _ <-+ runSession+ ( do+ let req = setPath defaultRequest (BS.concat ["/authorize?", LBS.toStrict authorizeQuery])+ res <- srequest (SRequest req LBS.empty)+ liftIO $ assertEqual "authorize status" status200 (simpleStatus res)+ )+ app++ loginRes <-+ runSession+ ( do+ let formBody =+ encodeForm+ [ ("username", "testuser")+ , ("password", "testpass")+ , ("client_id", clientId)+ , ("redirect_uri", redirectUri)+ , ("scope", "read")+ , ("state", "state-xyz")+ , ("code_challenge", challenge)+ , ("code_challenge_method", "S256")+ ]+ req =+ SRequest+ (setPath defaultRequest "/authorize/callback")+ { requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- srequest req+ liftIO $ assertEqual "callback redirect" status303 (simpleStatus res)+ pure res+ )+ app+ case lookup hLocation (simpleHeaders loginRes) of+ Nothing -> assertFailure "authorize callback missing Location"+ Just loc -> do+ let locText = TE.decodeUtf8 loc+ assertBool "redirect URI echoed" (redirectUri `T.isPrefixOf` locText)+ assertBool "state parameter included" ("state=state-xyz" `T.isInfixOf` locText)++ st2 <- readMVar stateVar+ let ac = fromJust $ find ((== clientId) . auth_code_client_id) (Map.elems (auth_codes st2))+ auth_code_challenge ac @?= Just challenge+ auth_code_challenge_method ac @?= Just "S256"+ assertBool "expiry set in future" (auth_code_expiry ac > now)++ (accessTokenValue, refreshTokenValue) <-+ runSession+ ( do+ let formBody =+ encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", auth_code_value ac)+ , ("redirect_uri", redirectUri)+ , ("client_id", clientId)+ , ("code_verifier", verifier)+ ]+ req =+ SRequest+ (setPath defaultRequest "/token")+ { requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- srequest req+ liftIO $ assertEqual "token exchange status" status200 (simpleStatus res)+ value <-+ case eitherDecode (simpleBody res) :: Either String Value of+ Left e -> abort e+ Right v -> pure v+ obj <- liftIO $ valueToObject value+ accessToken <- liftIO $ lookupTextFromObject "access_token" obj+ refreshToken <- liftIO $ lookupTextFromObject "refresh_token" obj+ pure (accessToken, refreshToken)+ )+ app+ assertBool "access token populated" (not (T.null accessTokenValue))+ assertBool "refresh token populated" (not (T.null refreshTokenValue))++ st3 <- readMVar stateVar+ Map.member (auth_code_value ac) (auth_codes st3) @?= False+ let persistence = refresh_token_persistence st3+ isJust <$> lookupRefreshToken persistence refreshTokenValue >>= (@?= True)++ (rotatedAccessToken, rotatedRefreshToken) <-+ runSession+ ( do+ let formBody =+ encodeForm+ [ ("grant_type", "refresh_token")+ , ("refresh_token", refreshTokenValue)+ , ("client_id", clientId)+ ]+ req =+ SRequest+ (setPath defaultRequest "/token")+ { requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ formBody+ res <- srequest req+ liftIO $ assertEqual "refresh status" status200 (simpleStatus res)+ value <-+ case eitherDecode (simpleBody res) :: Either String Value of+ Left e -> abort e+ Right v -> pure v+ obj <- liftIO $ valueToObject value+ accessTok <- liftIO $ lookupTextFromObject "access_token" obj+ refreshTok <- liftIO $ lookupTextFromObject "refresh_token" obj+ pure (accessTok, refreshTok)+ )+ app+ assertBool "rotated access token populated" (not (T.null rotatedAccessToken))+ assertBool "refresh token rotated" (rotatedRefreshToken /= refreshTokenValue)++ st4 <- readMVar stateVar+ let persistence' = refresh_token_persistence st4+ isJust <$> lookupRefreshToken persistence' refreshTokenValue >>= (@?= False)+ isJust <$> lookupRefreshToken persistence' rotatedRefreshToken >>= (@?= True)++lookupText :: Text -> KM.KeyMap Value -> IO Text+lookupText key obj = lookupTextFromObject key obj++lookupTextFromObject :: Text -> KM.KeyMap Value -> IO Text+lookupTextFromObject key obj =+ case KM.lookup (Key.fromText key) obj of+ Just (String t) -> pure t+ _ -> abort ("missing text field: " <> T.unpack key)++valueToObject :: Value -> IO (KM.KeyMap Value)+valueToObject (Object o) = pure o+valueToObject _ = abort "expected JSON object"++base64UrlHash :: Text -> Text+base64UrlHash verifier =+ let bytes = TE.encodeUtf8 verifier+ digest = hashWith SHA256 bytes :: Digest SHA256+ in TE.decodeUtf8 (B64URL.encodeUnpadded (BS.pack (BA.unpack digest)))++abort :: (MonadIO m, MonadFail m) => String -> m a+abort msg = liftIO (assertFailure msg) >> fail msg
+ test/Web/OAuth2/MetadataSpec.hs view
@@ -0,0 +1,69 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}++module Web.OAuth2.MetadataSpec (tests) where++import Control.Concurrent.MVar (MVar, modifyMVar_)+import Data.Aeson (Value (..), eitherDecode)+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Network.HTTP.Types (status200)+import Network.Wai (Application)+import Network.Wai.Test+import Test.Tasty+import Test.Tasty.HUnit+import Web.OAuth2.TestUtils+import Web.OAuth2.Types++tests :: TestTree+tests =+ testGroup+ "Metadata endpoint"+ [ returnsExpectedEndpoints+ , handlesExplicitPortGracefully+ ]++withApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a+withApp action = do+ (stateVar, _ctx, app) <- createTestApplication+ action stateVar app++fetchMetadata :: Application -> IO Value+fetchMetadata app = do+ res <-+ runSession+ (srequest (SRequest (setPath defaultRequest "/.well-known/oauth-authorization-server") LBS.empty))+ app+ simpleStatus res @?= status200+ case eitherDecode (simpleBody res) of+ Left err -> assertFailure ("Failed to decode metadata: " <> err)+ Right val -> pure val++returnsExpectedEndpoints :: TestTree+returnsExpectedEndpoints = testCase "returns issuer and endpoints for default configuration" $+ withApp $ \_ app -> do+ val <- fetchMetadata app+ case val of+ Object obj -> do+ KM.lookup "issuer" obj @?= Just (String "http://localhost:8080")+ KM.lookup "authorization_endpoint" obj @?= Just (String "http://localhost:8080/authorize")+ KM.lookup "token_endpoint" obj @?= Just (String "http://localhost:8080/token")+ KM.lookup "registration_endpoint" obj @?= Just (String "http://localhost:8080/register")+ case KM.lookup "token_endpoint_auth_methods_supported" obj of+ Just (Array arr) ->+ toList arr @?= [String "none", String "client_secret_post"]+ _ -> assertFailure "token_endpoint_auth_methods_supported missing"+ _ -> assertFailure "metadata response not an object"++handlesExplicitPortGracefully :: TestTree+handlesExplicitPortGracefully = testCase "does not duplicate port when oauth_url already includes it" $+ withApp $ \stateVar app -> do+ modifyMVar_ stateVar $ \s -> pure s{oauth_url = "https://dpella.example:8443/", oauth_port = 8443}+ val <- fetchMetadata app+ case val of+ Object obj -> do+ KM.lookup "issuer" obj @?= Just (String "https://dpella.example:8443/")+ KM.lookup "authorization_endpoint" obj @?= Just (String "https://dpella.example:8443/authorize")+ KM.lookup "token_endpoint" obj @?= Just (String "https://dpella.example:8443/token")+ _ -> assertFailure "metadata response not an object"
+ test/Web/OAuth2/RegisterSpec.hs view
@@ -0,0 +1,487 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module Web.OAuth2.RegisterSpec (tests) where++import Control.Concurrent.MVar (MVar, readMVar)+import Data.Aeson+import Data.Aeson.Key (toText)+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Char8 qualified as BS8+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+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 Network.HTTP.Types (HeaderName, hAuthorization, hContentType, methodDelete, methodPost, methodPut, status200, status201, status204, status400, status401, status404)+import Network.Wai (Application, requestHeaders, requestMethod)+import Network.Wai.Test+import Test.Tasty+import Test.Tasty.HUnit+import Web.OAuth2.TestUtils+import Web.OAuth2.Types++tests :: TestTree+tests =+ testGroup+ "Register endpoint"+ [ appliesDefaultsForPublicClients+ , issuesSecretForConfidentialClients+ , exposesManagementCredentials+ , managementGetReturnsClientMetadata+ , managementRejectsInvalidToken+ , managementUpdateAppliesChanges+ , managementDeleteRemovesClient+ , honorsRequestedScope+ , rejectsEmptyRedirectUris+ , rejectsRelativeRedirectUris+ , rejectsInsecureRedirectUris+ , acceptsExtendedLoopbackRedirects+ , rejectsUnsupportedAuthMethod+ ]++withApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a+withApp action = do+ (stateVar, _ctx, app) <- createTestApplication+ action stateVar app++registerClient :: Application -> Value -> IO SResponse+registerClient app payload = do+ let req =+ SRequest+ ( setPath defaultRequest "/register"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/json")]+ }+ (encode payload)+ runSession (srequest req) app++extractObject :: LBS.ByteString -> IO Object+extractObject body =+ case eitherDecode body of+ Left err -> assertFailure ("Failed to decode registration response: " <> err)+ Right (Object o) -> pure o+ Right _ -> assertFailure "Expected JSON object in registration response"++decodeRegistrationError :: LBS.ByteString -> IO OAuthError+decodeRegistrationError body =+ case eitherDecode body of+ Left err -> assertFailure ("Failed to decode registration error: " <> err)+ Right val -> pure val++appliesDefaultsForPublicClients :: TestTree+appliesDefaultsForPublicClients = testCase "fills defaults for omitted fields" $+ withApp $ \stateVar app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Minimal App" :: Text)+ , "redirect_uris" .= ["http://localhost:4000/callback" :: Text]+ ]+ )+ simpleStatus res @?= status201+ obj <- extractObject (simpleBody res)+ let getTextField key =+ case KM.lookup key obj of+ Just (String t) -> pure t+ _ -> assertFailure ("Missing text field: " <> T.unpack (toText key))+ getTextArray key =+ case KM.lookup key obj of+ Just (Array arr) ->+ pure [t | String t <- toList arr]+ _ -> assertFailure ("Missing array field: " <> T.unpack (toText key))+ clientId <- getTextField "client_id"+ assertBool "client_id non-empty" (not (T.null clientId))+ tokenMethod <- getTextField "token_endpoint_auth_method"+ tokenMethod @?= "none"+ grants <- getTextArray "grant_types"+ grants @?= ["authorization_code", "refresh_token"]+ responses <- getTextArray "response_types"+ responses @?= ["code"]+ scopeVal <- getTextField "scope"+ scopeVal @?= "read write"+ regToken <- getTextField "registration_access_token"+ assertBool "registration_access_token non-empty" (not (T.null regToken))+ regUri <- getTextField "registration_client_uri"+ assertBool "registration_client_uri includes client id" (clientId `T.isInfixOf` regUri)+ assertBool "client_secret absent" (KM.lookup "client_secret" obj == Nothing)+ assertBool "client_secret_expires_at absent" (KM.lookup "client_secret_expires_at" obj == Nothing)++ st <- readMVar stateVar+ case Map.lookup clientId (registered_clients st) of+ Just c -> do+ registered_client_grant_types c @?= ["authorization_code", "refresh_token"]+ registered_client_secret c @?= Nothing+ registered_client_registration_access_token c @?= Just regToken+ Nothing -> assertFailure "Client not persisted in state"++issuesSecretForConfidentialClients :: TestTree+issuesSecretForConfidentialClients = testCase "returns secret for confidential registration and stores it" $+ withApp $ \stateVar app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Confidential" :: Text)+ , "redirect_uris" .= ["https://localhost/callback" :: Text]+ , "grant_types" .= ["authorization_code" :: Text]+ , "token_endpoint_auth_method" .= ("client_secret_post" :: Text)+ ]+ )+ simpleStatus res @?= status201+ obj <- extractObject (simpleBody res)+ secretField <-+ case KM.lookup "client_secret" obj of+ Just (String s) -> pure s+ _ -> assertFailure "client_secret missing"+ let expiryField = KM.lookup "client_secret_expires_at" obj+ assertBool "expiry omitted" (expiryField == Nothing)+ clientId <-+ case KM.lookup "client_id" obj of+ Just (String cid) -> pure cid+ _ -> assertFailure "client_id missing"+ assertBool "secret non-empty" (not (T.null secretField))+ registrationToken <-+ case KM.lookup "registration_access_token" obj of+ Just (String tok) -> pure tok+ _ -> assertFailure "registration_access_token missing"++ st <- readMVar stateVar+ case Map.lookup clientId (registered_clients st) of+ Nothing -> assertFailure "Client not persisted for confidential registration"+ Just client -> do+ registered_client_secret client @?= Just secretField+ registered_client_token_endpoint_auth_method client @?= "client_secret_post"+ registered_client_registration_access_token client @?= Just registrationToken++exposesManagementCredentials :: TestTree+exposesManagementCredentials = testCase "includes management token and URI in response" $+ withApp $ \stateVar app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Management App" :: Text)+ , "redirect_uris" .= ["http://localhost:4000/callback" :: Text]+ ]+ )+ simpleStatus res @?= status201+ obj <- extractObject (simpleBody res)+ clientId <-+ case KM.lookup "client_id" obj of+ Just (String cid) -> pure cid+ _ -> assertFailure "client_id missing"+ managementToken <-+ case KM.lookup "registration_access_token" obj of+ Just (String tok) -> pure tok+ _ -> assertFailure "registration_access_token missing"+ managementUri <-+ case KM.lookup "registration_client_uri" obj of+ Just (String uriTxt) -> pure uriTxt+ _ -> assertFailure "registration_client_uri missing"+ managementUri @?= "http://localhost:8080/register/" <> clientId+ st <- readMVar stateVar+ case Map.lookup clientId (registered_clients st) of+ Nothing -> assertFailure "Client not persisted"+ Just client ->+ registered_client_registration_access_token client @?= Just managementToken++managementGetReturnsClientMetadata :: TestTree+managementGetReturnsClientMetadata = testCase "management GET returns stored metadata" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Mgmt GET" :: Text)+ , "redirect_uris" .= ["http://localhost:4000/callback" :: Text]+ ]+ )+ simpleStatus res @?= status201+ regObj <- extractObject (simpleBody res)+ clientId <- requireTextField "client_id" regObj+ managementToken <- requireTextField "registration_access_token" regObj+ getRes <-+ runSession+ ( srequest+ ( SRequest+ ( setPath defaultRequest (registrationPath clientId)+ ){ requestHeaders = [bearerHeader managementToken]+ }+ LBS.empty+ )+ )+ app+ simpleStatus getRes @?= status200+ getObj <- extractObject (simpleBody getRes)+ KM.lookup "client_id" getObj @?= Just (String clientId)+ KM.lookup "registration_access_token" getObj @?= Just (String managementToken)+ KM.lookup "registration_client_uri" getObj @?= Just (String ("http://localhost:8080/register/" <> clientId))++managementRejectsInvalidToken :: TestTree+managementRejectsInvalidToken = testCase "management endpoints reject missing or invalid tokens" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Mgmt Invalid" :: Text)+ , "redirect_uris" .= ["http://localhost:4000/callback" :: Text]+ ]+ )+ simpleStatus res @?= status201+ regObj <- extractObject (simpleBody res)+ clientId <- requireTextField "client_id" regObj+ -- Missing header+ resMissing <-+ runSession+ ( srequest+ ( SRequest+ ( setPath defaultRequest (registrationPath clientId)+ ){ requestHeaders = []+ }+ LBS.empty+ )+ )+ app+ simpleStatus resMissing @?= status401+ -- Wrong token+ resWrong <-+ runSession+ ( srequest+ ( SRequest+ ( setPath defaultRequest (registrationPath clientId)+ ){ requestHeaders = [bearerHeader "not-the-token"]+ }+ LBS.empty+ )+ )+ app+ simpleStatus resWrong @?= status401++managementUpdateAppliesChanges :: TestTree+managementUpdateAppliesChanges = testCase "PUT replaces client metadata and persists" $+ withApp $ \stateVar app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Mgmt Update" :: Text)+ , "redirect_uris" .= ["https://localhost/callback" :: Text]+ , "scope" .= ("read write" :: Text)+ ]+ )+ simpleStatus res @?= status201+ regObj <- extractObject (simpleBody res)+ clientId <- requireTextField "client_id" regObj+ managementToken <- requireTextField "registration_access_token" regObj+ let updatePayload =+ object+ [ "client_name" .= ("Mgmt Update v2" :: Text)+ , "redirect_uris" .= ["https://localhost/updated" :: Text]+ , "scope" .= ("profile email" :: Text)+ , "grant_types" .= ["authorization_code", "refresh_token" :: Text]+ , "response_types" .= ["code" :: Text]+ , "token_endpoint_auth_method" .= ("none" :: Text)+ ]+ updateRes <-+ runSession+ ( srequest+ ( SRequest+ ( setPath defaultRequest (registrationPath clientId)+ ){ requestMethod = methodPut+ , requestHeaders =+ [ bearerHeader managementToken+ , (hContentType, "application/json")+ ]+ }+ (encode updatePayload)+ )+ )+ app+ simpleStatus updateRes @?= status200+ updateObj <- extractObject (simpleBody updateRes)+ KM.lookup "scope" updateObj @?= Just (String "profile email")+ KM.lookup "client_name" updateObj @?= Just (String "Mgmt Update v2")+ st <- readMVar stateVar+ case Map.lookup clientId (registered_clients st) of+ Nothing -> assertFailure "Client missing after update"+ Just client -> do+ registered_client_scope client @?= "profile email"+ registered_client_redirect_uris client @?= ["https://localhost/updated"]++managementDeleteRemovesClient :: TestTree+managementDeleteRemovesClient = testCase "DELETE removes client and subsequent GET returns 404" $+ withApp $ \stateVar app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Mgmt Delete" :: Text)+ , "redirect_uris" .= ["http://localhost:4000/callback" :: Text]+ ]+ )+ simpleStatus res @?= status201+ regObj <- extractObject (simpleBody res)+ clientId <- requireTextField "client_id" regObj+ managementToken <- requireTextField "registration_access_token" regObj+ deleteRes <-+ runSession+ ( srequest+ ( SRequest+ ( setPath defaultRequest (registrationPath clientId)+ ){ requestMethod = methodDelete+ , requestHeaders = [bearerHeader managementToken]+ }+ LBS.empty+ )+ )+ app+ simpleStatus deleteRes @?= status204+ st <- readMVar stateVar+ Map.lookup clientId (registered_clients st) @?= Nothing+ afterDelete <-+ runSession+ ( srequest+ ( SRequest+ ( setPath defaultRequest (registrationPath clientId)+ ){ requestHeaders = [bearerHeader managementToken]+ }+ LBS.empty+ )+ )+ app+ simpleStatus afterDelete @?= status404++honorsRequestedScope :: TestTree+honorsRequestedScope = testCase "persists provided scope field" $+ withApp $ \stateVar app -> do+ let customScope = "profile email"+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Scoped App" :: Text)+ , "redirect_uris" .= ["https://localhost/callback" :: Text]+ , "scope" .= customScope+ ]+ )+ simpleStatus res @?= status201+ obj <- extractObject (simpleBody res)+ case KM.lookup "scope" obj of+ Just (String scopeVal) -> scopeVal @?= customScope+ _ -> assertFailure "scope field missing in response"+ case KM.lookup "client_id" obj of+ Just (String cid) -> do+ st <- readMVar stateVar+ case Map.lookup cid (registered_clients st) of+ Nothing -> assertFailure "registered client missing from state"+ Just client -> registered_client_scope client @?= customScope+ metadataRes <-+ runSession+ ( srequest+ (SRequest (setPath defaultRequest "/.well-known/oauth-authorization-server") LBS.empty)+ )+ app+ simpleStatus metadataRes @?= status200+ metadataObj <-+ case eitherDecode (simpleBody metadataRes) of+ Left err -> assertFailure ("Failed to decode metadata: " <> err)+ Right (Object o) -> pure o+ Right _ -> assertFailure "Metadata response not an object"+ case KM.lookup "scopes_supported" metadataObj of+ Just (Array arr) ->+ let scopes = [t | String t <- toList arr]+ in assertBool "metadata scopes include custom scope tokens" (all (`elem` scopes) (T.words customScope))+ _ -> assertFailure "scopes_supported missing from metadata"+ _ -> assertFailure "client_id missing in response"++rejectsEmptyRedirectUris :: TestTree+rejectsEmptyRedirectUris = testCase "rejects registrations without redirect URIs" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("No Redirects" :: Text)+ , "redirect_uris" .= ([] :: [Text])+ ]+ )+ simpleStatus res @?= status400+ err <- decodeRegistrationError (simpleBody res)+ Web.OAuth2.Types.error err @?= "invalid_client_metadata"++registrationPath :: Text -> BS8.ByteString+registrationPath clientId = BS8.concat ["/register/", TE.encodeUtf8 clientId]++bearerHeader :: Text -> (HeaderName, BS8.ByteString)+bearerHeader token = (hAuthorization, TE.encodeUtf8 ("Bearer " <> token))++requireTextField :: Text -> Object -> IO Text+requireTextField key obj =+ case KM.lookup (Key.fromText key) obj of+ Just (String val) -> pure val+ _ -> assertFailure ("Missing text field: " <> T.unpack key)++rejectsUnsupportedAuthMethod :: TestTree+rejectsUnsupportedAuthMethod = testCase "rejects token auth methods the server cannot fulfill" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Bad Auth" :: Text)+ , "redirect_uris" .= ["https://localhost/callback" :: Text]+ , "token_endpoint_auth_method" .= ("client_secret_basic" :: Text)+ ]+ )+ simpleStatus res @?= status400+ err <- decodeRegistrationError (simpleBody res)+ Web.OAuth2.Types.error err @?= "invalid_client_metadata"++rejectsRelativeRedirectUris :: TestTree+rejectsRelativeRedirectUris = testCase "rejects non-absolute redirect URIs" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Relative Redirect" :: Text)+ , "redirect_uris" .= ["/callback" :: Text]+ ]+ )+ simpleStatus res @?= status400+ err <- decodeRegistrationError (simpleBody res)+ Web.OAuth2.Types.error err @?= "invalid_client_metadata"++rejectsInsecureRedirectUris :: TestTree+rejectsInsecureRedirectUris = testCase "rejects non-loopback http redirect URIs" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Insecure Redirect" :: Text)+ , "redirect_uris" .= ["http://example.com/callback" :: Text]+ ]+ )+ simpleStatus res @?= status400+ err <- decodeRegistrationError (simpleBody res)+ Web.OAuth2.Types.error err @?= "invalid_client_metadata"++acceptsExtendedLoopbackRedirects :: TestTree+acceptsExtendedLoopbackRedirects = testCase "accepts any 127.0.0.0/8 redirect host" $+ withApp $ \_ app -> do+ res <-+ registerClient+ app+ ( object+ [ "client_name" .= ("Loopback" :: Text)+ , "redirect_uris" .= ["http://127.10.20.30/callback" :: Text]+ ]+ )+ simpleStatus res @?= status201
+ test/Web/OAuth2/TestUtils.hs view
@@ -0,0 +1,228 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}++module Web.OAuth2.TestUtils+ ( -- * Shared fixtures+ TestUser (..)+ , testUser+ , TestAuthSettings (..)+ , mkAuthCode+ , mkRegisteredClient+ , mkPublicClient+ , mkConfidentialClient+ , mkTrackingPersistence+ , mkState+ , mkJWTSettings+ , jwtContext+ , formAuthContext+ , runHandler+ , encodeForm+ -- * Application helpers+ , emptyOAuthState+ , createTestContext+ , createTestApplication+ , addRegisteredClientToState+ , addAuthCodeToState+ , addRefreshTokenToState+ ) where++import Control.Concurrent.MVar+import Control.Monad (void)+import Data.Aeson (FromJSON, ToJSON)+import Data.ByteString.Lazy qualified as LBS+import Data.IORef+import Data.List (find)+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 (Generic)+import Network.Wai (Application)+import Servant (Context (EmptyContext, (:.)), Handler, Proxy (..), ServerError, serveWithContext)+import Servant.Auth.Server+import Servant.Server.Internal.Handler qualified as Internal+import Web.OAuth2+import Web.OAuth2.Types++data TestUser = TestUser+ { userId :: Text+ , userName :: Text+ , userEmail :: Text+ }+ deriving (Eq, Show, Generic, ToJSON, FromJSON)++instance ToJWT TestUser++instance FromJWT TestUser++instance FormAuth TestUser where+ type FormAuthSettings TestUser = TestAuthSettings+ runFormAuth _ username password =+ pure $+ case (username, password) of+ ("testuser", "testpass") -> Authenticated testUser+ ("admin", "admin") -> Authenticated (TestUser "admin" "Admin User" "admin@example.com")+ _ -> NoSuchUser++data TestAuthSettings = TestAuthSettings++testUser :: TestUser+testUser = TestUser "user1" "Test User" "test@example.com"++mkAuthCode+ :: Text+ -> RegisteredClient+ -> TestUser+ -> UTCTime+ -> Text+ -> Maybe Text+ -> Maybe Text+ -> AuthCode TestUser+mkAuthCode codeValue RegisteredClient{registered_client_id, registered_client_scope} usr expiry redirectUri challenge challengeMethod =+ AuthCode+ { auth_code_value = codeValue+ , auth_code_client_id = registered_client_id+ , auth_code_user = usr+ , auth_code_redirect_uri = redirectUri+ , auth_code_scope = registered_client_scope+ , auth_code_expiry = expiry+ , auth_code_challenge = challenge+ , auth_code_challenge_method = challengeMethod+ }++mkRegisteredClient+ :: Text+ -> [Text]+ -> [Text]+ -> [Text]+ -> Text+ -> Text+ -> Maybe Text+ -> RegisteredClient+mkRegisteredClient clientId redirects grants responses scope tokenMethod secret =+ RegisteredClient+ { registered_client_id = clientId+ , registered_client_name = clientId+ , registered_client_secret = secret+ , registered_client_redirect_uris = redirects+ , registered_client_grant_types = grants+ , registered_client_response_types = responses+ , registered_client_scope = scope+ , registered_client_token_endpoint_auth_method = tokenMethod+ , registered_client_registration_access_token = Nothing+ }++mkPublicClient :: Text -> [Text] -> Text -> RegisteredClient+mkPublicClient clientId redirectUris scope =+ mkRegisteredClient clientId redirectUris ["authorization_code", "refresh_token"] ["code"] scope "none" Nothing++mkConfidentialClient :: Text -> Text -> [Text] -> Text -> RegisteredClient+mkConfidentialClient clientId secret redirectUris scope =+ mkRegisteredClient clientId redirectUris ["authorization_code", "refresh_token"] ["code"] scope "client_secret_post" (Just secret)++mkTrackingPersistence+ :: IO (RefreshTokenPersistence TestUser, IO [RefreshToken TestUser])+mkTrackingPersistence = do+ ref <- newIORef ([] :: [RefreshToken TestUser])+ let persist token =+ atomicModifyIORef' ref $ \tokens -> (token : tokens, ())+ delete tokenValue =+ atomicModifyIORef' ref $ \tokens ->+ (filter ((/= tokenValue) . refresh_token_value) tokens, ())+ lookupToken tokenValue = do+ tokens <- readIORef ref+ pure $ find ((== tokenValue) . refresh_token_value) tokens+ pure+ ( RefreshTokenPersistence+ { persistRefreshToken = persist+ , deleteRefreshToken = delete+ , lookupRefreshToken = lookupToken+ }+ , reverse <$> readIORef ref+ )++mkState+ :: RefreshTokenPersistence TestUser+ -> [RegisteredClient]+ -> [(Text, AuthCode TestUser)]+ -> IO (MVar (OAuthState TestUser))+mkState persistence clients codes =+ newMVar+ OAuthState+ { auth_codes = Map.fromList codes+ , refresh_token_persistence = persistence+ , registered_clients =+ Map.fromList $ fmap (\c -> (registered_client_id c, c)) clients+ , oauth_url = "https://auth.example.com"+ , oauth_port = 443+ , login_form_renderer = defaultLoginFormRenderer+ }++mkJWTSettings :: IO JWTSettings+mkJWTSettings = defaultJWTSettings <$> generateKey++jwtContext :: JWTSettings -> Context '[JWTSettings]+jwtContext settings = settings :. EmptyContext++formAuthContext :: Context '[TestAuthSettings]+formAuthContext = TestAuthSettings :. EmptyContext++runHandler :: Handler a -> IO (Either ServerError a)+runHandler = Internal.runHandler++encodeForm :: [(Text, Text)] -> LBS.ByteString+encodeForm fields =+ let fragment (k, v) = k <> "=" <> v+ body = T.intercalate "&" (map fragment fields)+ in LBS.fromStrict (TE.encodeUtf8 body)++emptyOAuthState :: IO (MVar (OAuthState TestUser))+emptyOAuthState = do+ persistence <- mkDefaultRefreshTokenPersistence+ newMVar (initOAuthState @TestUser "http://localhost:8080" 8080 persistence defaultLoginFormRenderer)++createTestContext :: IO (Context '[JWTSettings, TestAuthSettings])+createTestContext = do+ jwt <- mkJWTSettings+ pure (jwt :. TestAuthSettings :. EmptyContext)++createTestApplication :: IO (MVar (OAuthState TestUser), Context '[JWTSettings, TestAuthSettings], Application)+createTestApplication = do+ stateVar <- emptyOAuthState+ ctx <- createTestContext+ let app = serveWithContext (Proxy :: Proxy OAuthAPI) ctx (oAuthAPI stateVar ctx)+ pure (stateVar, ctx, app)++addRegisteredClientToState :: MVar (OAuthState TestUser) -> RegisteredClient -> IO ()+addRegisteredClientToState st client =+ void $+ modifyMVar st $ \s -> do+ let updated =+ s+ { registered_clients =+ Map.insert (registered_client_id client) client (registered_clients s)+ }+ pure (updated, ())++addAuthCodeToState :: MVar (OAuthState TestUser) -> AuthCode TestUser -> IO ()+addAuthCodeToState st ac =+ void $+ modifyMVar st $ \s -> do+ let updated = s{auth_codes = Map.insert (auth_code_value ac) ac (auth_codes s)}+ pure (updated, ())++addRefreshTokenToState :: MVar (OAuthState TestUser) -> RefreshToken TestUser -> IO ()+addRefreshTokenToState st rt =+ void $+ modifyMVar st $ \s -> do+ persistRefreshToken (refresh_token_persistence s) rt+ pure (s, ())
+ test/Web/OAuth2/TokenSpec.hs view
@@ -0,0 +1,540 @@+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}++module Web.OAuth2.TokenSpec (tests) where++import Control.Concurrent.Async (concurrently)+import Control.Concurrent.MVar (MVar, readMVar)+import Crypto.Hash (SHA256 (..), hashWith)+import Data.Aeson (Value (..), eitherDecode)+import Data.Aeson.KeyMap qualified as KM+import Data.ByteArray qualified as BA+import Data.ByteString qualified as BS+import Data.ByteString.Base64.URL qualified as B64URL+import Data.ByteString.Char8 qualified as BS8+import Data.ByteString.Lazy qualified as LBS+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Maybe (isJust, isNothing)+import Data.Text (Text)+import Data.Text.Encoding qualified as TE+import Data.Time.Clock+import Network.HTTP.Types (hContentType, methodPost, status200, status400, status401)+import Network.Wai (Application, requestHeaders, requestMethod)+import Network.Wai.Test+import Servant.API.ResponseHeaders (getHeaders, getResponse)+import Test.Tasty+import Test.Tasty.HUnit+import Web.OAuth2.Internal (TokenRequest (..), TokenResponse (..), TokenResponseHeaders, handleTokenRequest)+import Web.OAuth2.TestUtils+import Web.OAuth2.Types hiding (error)+import Web.OAuth2.Types qualified as OAuthTypes++tests :: TestTree+tests =+ testGroup+ "Token endpoint"+ [ tokenEndpointIntegrationTests+ , handlerLevelTests+ ]++tokenEndpointIntegrationTests :: TestTree+tokenEndpointIntegrationTests =+ testGroup+ "Integration"+ [ rejectsMissingVerifier+ , rejectsInvalidVerifier+ , rejectsExpiredAuthCode+ , confidentialClientsRequireSecret+ , rejectsUnknownRefreshToken+ , rejectsRefreshScopeEscalation+ , authorizationCodeSingleUseConcurrent+ , refreshTokenSingleUseConcurrent+ , rejectsDisallowedGrantType+ , rejectsRefreshTokenClientMismatch+ , pkceS256AcceptsValidVerifier+ , pkceDefaultsToPlainWhenMethodOmitted+ , rejectsUnsupportedPkceMethod+ ]++handlerLevelTests :: TestTree+handlerLevelTests =+ testGroup+ "Handler behaviour"+ [ noRefreshTokenIssuedForClientsWithoutGrant+ , refreshTokenIssuedWhenAllowed+ ]++withFreshApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a+withFreshApp action = do+ (stateVar, _ctx, app) <- createTestApplication+ action stateVar app++decodeOAuthError :: LBS.ByteString -> IO OAuthError+decodeOAuthError body =+ case eitherDecode body of+ Left err -> assertFailure ("Failed to decode OAuthError: " <> err)+ Right val -> pure val++postToken :: Application -> LBS.ByteString -> IO SResponse+postToken app body = do+ let req =+ SRequest+ ( setPath defaultRequest "/token"+ ){ requestMethod = methodPost+ , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]+ }+ body+ runSession (srequest req) app++mkAuthCodeEntry+ :: Text+ -> Text+ -> Text+ -> Text+ -> UTCTime+ -> Maybe Text+ -> Maybe Text+ -> AuthCode TestUser+mkAuthCodeEntry value clientId redirect scope expiry challenge method =+ AuthCode+ { auth_code_value = value+ , auth_code_client_id = clientId+ , auth_code_user = testUser+ , auth_code_redirect_uri = redirect+ , auth_code_scope = scope+ , auth_code_expiry = expiry+ , auth_code_challenge = challenge+ , auth_code_challenge_method = method+ }++rejectsMissingVerifier :: TestTree+rejectsMissingVerifier = testCase "enforces PKCE code_verifier when challenge stored" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-1" ["http://localhost:4000/cb"] "read write")+ now <- getCurrentTime+ let authCode = mkAuthCodeEntry "code-pkce" "pub-1" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "challenge") (Just "plain")+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-pkce")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-1")+ ]+ )+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_request"++rejectsInvalidVerifier :: TestTree+rejectsInvalidVerifier = testCase "rejects mismatched code_verifier" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-2" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let authCode = mkAuthCodeEntry "code-wrong" "pub-2" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "correct") (Just "plain")+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-wrong")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-2")+ , ("code_verifier", "incorrect")+ ]+ )+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_grant"+ OAuthTypes.error_description errResp @?= Just "Invalid code verifier"++rejectsExpiredAuthCode :: TestTree+rejectsExpiredAuthCode = testCase "rejects expired authorization codes" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-3" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let authCode = mkAuthCodeEntry "code-expired" "pub-3" "http://localhost:4000/cb" "read" (addUTCTime (-30) now) (Just "verifier") (Just "plain")+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-expired")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-3")+ , ("code_verifier", "verifier")+ ]+ )+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_grant"+ OAuthTypes.error_description errResp @?= Just "Authorization code expired"++confidentialClientsRequireSecret :: TestTree+confidentialClientsRequireSecret = testCase "confidential clients must provide client_secret" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkConfidentialClient "conf-1" "top-secret" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let authCode = mkAuthCodeEntry "code-conf" "conf-1" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "secret") Nothing+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-conf")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "conf-1")+ , ("code_verifier", "secret")+ ]+ )+ simpleStatus res @?= status401+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_client"+ st <- readMVar stateVar+ Map.member "code-conf" (auth_codes st) @?= True++rejectsUnknownRefreshToken :: TestTree+rejectsUnknownRefreshToken = testCase "rejects refresh_token grant when token missing" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-4" ["http://localhost:4000/cb"] "read")+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "refresh_token")+ , ("refresh_token", "does-not-exist")+ , ("client_id", "pub-4")+ ]+ )+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_grant"++rejectsRefreshScopeEscalation :: TestTree+rejectsRefreshScopeEscalation = testCase "rejects refresh token with scope outside client allowance" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-5" ["http://localhost:4000/cb"] "read")+ let refreshTok =+ RefreshToken+ { refresh_token_value = "rt-1"+ , refresh_token_client_id = "pub-5"+ , refresh_token_user = testUser+ , refresh_token_scope = "write"+ }+ addRefreshTokenToState stateVar refreshTok+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "refresh_token")+ , ("refresh_token", "rt-1")+ , ("client_id", "pub-5")+ ]+ )+ simpleStatus res @?= status400+ lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_scope"++authorizationCodeSingleUseConcurrent :: TestTree+authorizationCodeSingleUseConcurrent = testCase "authorization codes cannot be redeemed concurrently" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-6" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let authCode = mkAuthCodeEntry "code-concurrent" "pub-6" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "verifier") (Just "plain")+ body =+ encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-concurrent")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-6")+ , ("code_verifier", "verifier")+ ]+ addAuthCodeToState stateVar authCode+ (resA, resB) <- concurrently (postToken app body) (postToken app body)+ let statuses = sort [simpleStatus resA, simpleStatus resB]+ statuses @?= [status200, status400]+ let (failureRes, successRes) = if simpleStatus resA == status400 then (resA, resB) else (resB, resA)+ lookup hContentType (simpleHeaders failureRes) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse failureRes+ errResp <- decodeOAuthError (simpleBody failureRes)+ OAuthTypes.error errResp @?= "invalid_grant"+ simpleStatus successRes @?= status200+ assertNoStoreHeadersResponse successRes+ stateAfter <- readMVar stateVar+ Map.member "code-concurrent" (auth_codes stateAfter) @?= False++refreshTokenSingleUseConcurrent :: TestTree+refreshTokenSingleUseConcurrent = testCase "refresh tokens rotate under concurrent use" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-7" ["http://localhost:4000/cb"] "read")+ let originalToken =+ RefreshToken+ { refresh_token_value = "rt-concurrent"+ , refresh_token_client_id = "pub-7"+ , refresh_token_user = testUser+ , refresh_token_scope = "read"+ }+ body =+ encodeForm+ [ ("grant_type", "refresh_token")+ , ("refresh_token", "rt-concurrent")+ , ("client_id", "pub-7")+ ]+ addRefreshTokenToState stateVar originalToken+ (resA, resB) <- concurrently (postToken app body) (postToken app body)+ let statuses = sort [simpleStatus resA, simpleStatus resB]+ statuses @?= [status200, status400]+ let (failureRes, successRes) = if simpleStatus resA == status400 then (resA, resB) else (resB, resA)+ lookup hContentType (simpleHeaders failureRes) @?= Just "application/json; charset=utf-8"+ assertNoStoreHeadersResponse failureRes+ errResp <- decodeOAuthError (simpleBody failureRes)+ OAuthTypes.error errResp @?= "invalid_grant"+ simpleStatus successRes @?= status200+ assertNoStoreHeadersResponse successRes+ successValue <-+ case eitherDecode (simpleBody successRes) :: Either String Value of+ Left err -> assertFailure ("Failed to decode success token response: " <> err)+ Right val -> pure val+ newRefresh <-+ case successValue of+ Object obj ->+ case KM.lookup "refresh_token" obj of+ Just (String t) -> pure t+ _ -> assertFailure "refresh_token missing from successful response"+ _ -> assertFailure "Expected object in token response"+ assertBool "rotation produced new token" (newRefresh /= refresh_token_value originalToken)+ stateAfter <- readMVar stateVar+ let persistence = refresh_token_persistence stateAfter+ oldToken <- lookupRefreshToken persistence (refresh_token_value originalToken)+ assertBool "old refresh token removed" (isNothing oldToken)+ newToken <- lookupRefreshToken persistence newRefresh+ assertBool "new refresh token persisted" (isJust newToken)++noRefreshTokenIssuedForClientsWithoutGrant :: TestTree+noRefreshTokenIssuedForClientsWithoutGrant =+ testCase "authorization_code clients without refresh grant do not receive refresh tokens" $ do+ (persistence, readPersisted) <- mkTrackingPersistence+ let client =+ mkRegisteredClient+ "public-client"+ ["https://client.example/callback"]+ ["authorization_code"]+ ["code"]+ "read"+ "none"+ Nothing+ expiry <- addUTCTime 600 <$> getCurrentTime+ let user = testUser+ authCode = mkAuthCode "auth-code-1" client user expiry "https://client.example/callback" (Just "verifier") Nothing+ stateVar <- mkState persistence [client] [("auth-code-1", authCode)]+ jwtSettings <- mkJWTSettings+ let tokenRequest =+ TokenRequest+ { grant_type = "authorization_code"+ , code = Just "auth-code-1"+ , refresh_token = Nothing+ , redirect_uri = Just "https://client.example/callback"+ , client_id = "public-client"+ , client_secret = Nothing+ , code_verifier = Just "verifier"+ }+ result <- runHandler $ handleTokenRequest stateVar (jwtContext jwtSettings) tokenRequest+ tokenResponseHeaders <-+ either (assertFailure . ("Token handler failed: " <>) . show) pure result+ let tokenResponse = getResponse tokenResponseHeaders+ assertBool "refresh_token should be omitted" (isNothing (refresh_token_resp tokenResponse))+ persisted <- readPersisted+ assertBool "no refresh token should be persisted" (null persisted)+ stateAfter <- readMVar stateVar+ assertBool "authorization code should be cleared" (null (auth_codes stateAfter))+ assertNoStoreHeadersFromHeaders tokenResponseHeaders++refreshTokenIssuedWhenAllowed :: TestTree+refreshTokenIssuedWhenAllowed =+ testCase "authorization_code clients with refresh grant receive refresh tokens" $ do+ (persistence, readPersisted) <- mkTrackingPersistence+ let client =+ mkRegisteredClient+ "confidential-client"+ ["https://conf.example/cb"]+ ["authorization_code", "refresh_token"]+ ["code"]+ "read"+ "none"+ Nothing+ expiry <- addUTCTime 600 <$> getCurrentTime+ let user = testUser+ authCode = mkAuthCode "auth-code-2" client user expiry "https://conf.example/cb" (Just "verifier") Nothing+ stateVar <- mkState persistence [client] [("auth-code-2", authCode)]+ jwtSettings <- mkJWTSettings+ let tokenRequest =+ TokenRequest+ { grant_type = "authorization_code"+ , code = Just "auth-code-2"+ , refresh_token = Nothing+ , redirect_uri = Just "https://conf.example/cb"+ , client_id = "confidential-client"+ , client_secret = Nothing+ , code_verifier = Just "verifier"+ }+ result <- runHandler $ handleTokenRequest stateVar (jwtContext jwtSettings) tokenRequest+ tokenResponseHeaders <-+ either (assertFailure . ("Token handler failed: " <>) . show) pure result+ let tokenResponse = getResponse tokenResponseHeaders+ assertBool "refresh_token should be present" (isJust (refresh_token_resp tokenResponse))+ persisted <- readPersisted+ assertEqual "a refresh token should be persisted" 1 (length persisted)+ stateAfter <- readMVar stateVar+ assertBool "authorization code should be cleared" (null (auth_codes stateAfter))+ assertNoStoreHeadersFromHeaders tokenResponseHeaders++assertNoStoreHeadersFromHeaders :: TokenResponseHeaders -> Assertion+assertNoStoreHeadersFromHeaders headers = do+ let actualHeaders = fmap (BS8.unpack . snd) (getHeaders headers)+ assertBool "Cache-Control no-store" ("no-store" `elem` actualHeaders)+ assertBool "Pragma no-cache" ("no-cache" `elem` actualHeaders)++assertNoStoreHeadersResponse :: SResponse -> Assertion+assertNoStoreHeadersResponse res = do+ lookup "Cache-Control" (simpleHeaders res) @?= Just "no-store"+ lookup "Pragma" (simpleHeaders res) @?= Just "no-cache"++rejectsDisallowedGrantType :: TestTree+rejectsDisallowedGrantType = testCase "rejects grant_type not in client's allowed grants" $+ withFreshApp $ \stateVar app -> do+ let client = mkRegisteredClient "authonly" ["http://localhost:4000/cb"] ["authorization_code"] ["code"] "read" "none" Nothing+ addRegisteredClientToState stateVar client+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "refresh_token")+ , ("refresh_token", "rt-any")+ , ("client_id", "authonly")+ ]+ )+ simpleStatus res @?= status400+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "unauthorized_client"++rejectsRefreshTokenClientMismatch :: TestTree+rejectsRefreshTokenClientMismatch = testCase "rejects refresh token issued to a different client" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "client-a" ["http://localhost:4000/cb"] "read")+ addRegisteredClientToState stateVar (mkPublicClient "client-b" ["http://localhost:4000/cb"] "read")+ let refreshTok =+ RefreshToken+ { refresh_token_value = "rt-stolen"+ , refresh_token_client_id = "client-a"+ , refresh_token_user = testUser+ , refresh_token_scope = "read"+ }+ addRefreshTokenToState stateVar refreshTok+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "refresh_token")+ , ("refresh_token", "rt-stolen")+ , ("client_id", "client-b")+ ]+ )+ simpleStatus res @?= status400+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_grant"+ OAuthTypes.error_description errResp @?= Just "Client ID mismatch"++-- | Compute a PKCE S256 challenge from a verifier.+s256Challenge :: Text -> Text+s256Challenge verifier =+ let hash = hashWith SHA256 (TE.encodeUtf8 verifier)+ hashBytes = BS.pack (BA.unpack hash)+ in TE.decodeUtf8 (B64URL.encodeUnpadded hashBytes)++pkceS256AcceptsValidVerifier :: TestTree+pkceS256AcceptsValidVerifier = testCase "accepts valid S256 PKCE code_verifier" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-s256" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"+ challenge = s256Challenge verifier+ authCode = mkAuthCodeEntry "code-s256" "pub-s256" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just challenge) (Just "S256")+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-s256")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-s256")+ , ("code_verifier", verifier)+ ]+ )+ simpleStatus res @?= status200+ assertNoStoreHeadersResponse res++pkceDefaultsToPlainWhenMethodOmitted :: TestTree+pkceDefaultsToPlainWhenMethodOmitted = testCase "PKCE defaults to plain verification when method is omitted" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-nomethod" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let verifier = "my-plain-verifier"+ authCode = mkAuthCodeEntry "code-nomethod" "pub-nomethod" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just verifier) Nothing+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-nomethod")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-nomethod")+ , ("code_verifier", verifier)+ ]+ )+ simpleStatus res @?= status200+ assertNoStoreHeadersResponse res++rejectsUnsupportedPkceMethod :: TestTree+rejectsUnsupportedPkceMethod = testCase "rejects unsupported PKCE code_challenge_method" $+ withFreshApp $ \stateVar app -> do+ addRegisteredClientToState stateVar (mkPublicClient "pub-badmethod" ["http://localhost:4000/cb"] "read")+ now <- getCurrentTime+ let authCode = mkAuthCodeEntry "code-badmethod" "pub-badmethod" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "challenge") (Just "RS256")+ addAuthCodeToState stateVar authCode+ res <-+ postToken+ app+ ( encodeForm+ [ ("grant_type", "authorization_code")+ , ("code", "code-badmethod")+ , ("redirect_uri", "http://localhost:4000/cb")+ , ("client_id", "pub-badmethod")+ , ("code_verifier", "challenge")+ ]+ )+ simpleStatus res @?= status400+ assertNoStoreHeadersResponse res+ errResp <- decodeOAuthError (simpleBody res)+ OAuthTypes.error errResp @?= "invalid_grant"