Security
Overview
Shopfoo implements a simplified but representative claims-based access control model. There is no real authentication layer (no Keycloak, no OAuth, no JWT verification): the Login page simply lets the user pick a persona/role for manual testing. This is sufficient for demo purposes while still exercising the same security patterns that a production app would use β claims checked both client-side and server-side.
Domain model
The security types are defined in Domain.Types/Security.fs.
A User is either Anonymous or LoggedIn with a name and a set of Claims. Claims map a Feat (feature area) to an Access level:
type Access = View | Edit
type Feat = About | Admin | Catalog | Sales | Warehouse
type Claims = Map<Feat, Access>
type User =
| Anonymous
| LoggedIn of userName: string * claims: ClaimsThe User type exposes helpers to check access:
member user.CanAccess feat = ... // bool β has any access to the feature
member user.AccessTo feat = ... // Access option β the specific access level
// Active patterns for pattern matching
let (|UserCanAccess|_|) feat (user: User) = ...
let (|UserCanNotAccess|_|) feat (user: User) = ...Login page β persona selection
Since there is no real authentication, the Login page (Pages/Login.fs) displays a table of predefined personas. The user clicks a row to "log in" as that persona.
The Persona type is a discriminated union defined in Domain.Types/Security.fs, alongside the other security types:
Guest
View
View
β
β
β
Catalog Editor
View
Edit
View
View
β
Sales
View
View
Edit
Edit
β
Product Manager
View
Edit
Edit
Edit
β
Administrator
View
Edit
Edit
Edit
Edit
Each case declares its claims explicitly as a Map literal β there is no incremental building.
Client-side access control
Page routing (View.fs)
View.fs)The main AppView determines which page to render based on the current route and the user's authentication state. Protected pages require a logged-in user; if the user is anonymous, the Login page is displayed inline (without URL redirection):
After rendering, a React.useEffect hook checks that the user has the required feature access. If not, it redirects to a "Not Found" page:
Conditional rendering in components
Individual components adapt their UI based on user claims.
Product Details page β the Actions column is only displayed when the user has Sales or Warehouse access and the product type supports it:
Actions form β within the Actions column, each action group uses AccessTo to determine the access level (View = read-only display, Edit = interactive actions):
Server-side authorization
Claims are also verified server-side on every Remoting API call. The mechanism relies on several types working together β from the client-side FullContext down to the server-side authorizeHandler. The full Remoting pipeline is detailed in the Remoting page; here we focus on the security aspects.
Passing the token with each request
The FullContext record (Shared/Remoting.fs) holds the current User, an optional AuthToken, and the loaded Translations (which carry their own Lang):
Every API call in Shared/Remoting.fs is typed as a function taking a Request<'t>:
The FullContext.PrepareRequest extension method (declared in Client/Remoting.fs) builds a Request by copying the Token from the context:
A convenience variant, PrepareQueryWithTranslations, wraps the query together with the translation pages to reload.
In practice, every page calls fullContext.PrepareRequest (or PrepareQueryWithTranslations) before invoking any API endpoint β for example:
Token issuance β server-side encryption
The AuthToken is a simple wrapper: type AuthToken = AuthToken of string.
When the Login page loads, it calls the HomeApi.Index endpoint which returns a list of AuthPersona records β each pairing a Persona with a pre-computed AuthToken:
The token is produced server-side in the IndexHandler by serializing the corresponding User to JSON and then encrypting it with AES-256-GCM:
tokenFor is defined in Security.fs:
The Crypto module (private to Security.fs) uses System.Security.Cryptography.AesGcm with an ephemeral 256-bit key generated at server startup. Each encrypted token is a Base64 string containing nonce (12 bytes) + cipherText + tag (16 bytes). Since the key lives only in memory, all tokens become invalid when the server restarts (users must re-login).
The token is opaque to the client β it never decodes or inspects it. Only the server can decrypt it.
When the user selects a persona, the client reconstructs the User and stores it alongside the Token in the FullContext via WithPersona:
From that point on, every API call made through PrepareRequest includes this token.
Authorization handler (Server/Remoting/Security.fs)
Server/Remoting/Security.fs)On the server, checkToken decrypts the token with Crypto.decrypt, then deserializes the JSON back into a User and compares its claims against the required ones. If decryption fails (tampered or forged token), the request is rejected with TokenInvalid. The authorizeHandler function wraps every API handler β it checks the token first, then delegates to the handler with the decoded User:
API endpoint authorization
Each API builder declares the required claims for its endpoints. For example:
This ensures that even if a user bypasses client-side checks, the server rejects unauthorized requests.
Testing Remoting API security
The RemotingApi/XxxApiSecurityTests classes verify that each endpoint of the related XxxApi is correctly secured. Unlike the lower-level AuthorizeHandlerShould tests (which test authorizeHandler in isolation with a stub handler), these tests build the real RootApi via Dependency Injection β the same way the server does β and call each endpoint with tokens from the actual personas.
Test fixture setup (RemotingApi/Helpers.fs):
Test helpers:
The Helpers.fs module provides a PersonaOrAnonymousEnum char enum used as [<Arguments>] in tests (char values avoid F# DU serialization issues in test runners):
The PersonaOrAnonymousEnumToken active pattern resolves an enum value to an AuthToken option, and expect dispatches to assertAccepted or assertRejected.
Test structure:
Tests are split into one class per API β AdminApiSecurityTests, CatalogApiSecurityTests, HomeApiSecurityTests, PricesApiSecurityTests. Each class uses [<MethodDataSource>] for sets of accepted/rejected personas, or [<Arguments>] for single-endpoint cases:
Test strategy:
Reject β a persona with insufficient claims gets
Error(ServerError.AuthError UserUnauthorized).Accept β a persona with sufficient claims gets a result that is not an
AuthError. The handler may succeed or return a business error; only the auth layer matters here.
Last updated