githubEdit

shield-userSecurity

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: Claims

The 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:

Persona
About
Catalog
Sales
Warehouse
Admin

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)

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).

circle-info

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)

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