# Auth & tenancy in the SPA

> How the AppointMe React SPA signs users in through the .NET API's cookie session, sends the antiforgery token, picks the current company and hides UI the user has no permission for.

Source page: https://appointme.dev/docs/frontend/auth-and-tenancy/

* **Session:** An HttpOnly `appointme.auth` cookie
* **CSRF:** `XSRF-TOKEN` cookie, sent back as `X-XSRF-TOKEN`
* **Company:** `currentCompanyStore`, sent as `X-Company-Id`
* **Permissions:** `GET /api/v1/me/access`, checked by `Can`

## Sign-in from the browser’s view

**The SPA never handles a token. It sends the browser to the API, the API runs OpenID Connect with the identity provider, and the browser comes back holding an HttpOnly cookie.**

*Figure 1. A full-page round trip. The SPA only ever sees the result of /api/v1/me.*

1. **/auth/login** — the `Login` page navigates to `/api/v1/login`
2. **LoginEndpoint** — an OIDC challenge to Keycloak or Entra
3. **/signin-oidc** — the API sets `appointme.auth`, then redirects back
4. **GET /api/v1/me** — `isAuthenticated: true`, plus the companies

| Step                 | Where                                                | What happens                                                                                                                       |
| -------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Find out who’s there | `AppShell` → `CurrentUserProvider`                   | `GET /api/v1/me` allows anonymous callers. `isAuthenticated: false` sends the router to `/auth/login`.                             |
| Leave the SPA        | `app/auth/login/login.tsx`                           | A full-page navigation to `/api/v1/login` with `returnUrl` and an optional `provider`                                              |
| Challenge            | `Identity/AppointMe.Identity/Login/LoginEndpoint.cs` | Accepts only a relative `returnUrl`. Under Keycloak, `provider` becomes `kc_idp_hint`, which the signup page’s Google button uses. |
| Callback             | `/signin-oidc`, proxied by Vite in development       | The API stores only the id_token in the cookie and redirects to `Frontend:BaseUrl` plus `returnUrl`                                |
| Back in the SPA      | `app/router/app-shell.tsx`                           | `/me` now returns the user, and the router picks the app, invitations or onboarding                                                |

The `Login` page does nothing but hand over to the API:

src/AppointMe.Frontend/src/app/auth/login/login.tsx

```tsx
    useEffect(() => {
        const returnUrl = params.get('returnUrl') ?? '/';
        const provider = params.get('provider');


        const query = new URLSearchParams();
        query.set('returnUrl', returnUrl);
        if (provider) {
            query.set('provider', provider);
        }


        window.location.href = `/api/v1/login?${query.toString()}`;
    }, [params]);
```

### On the server

The API’s default scheme, `Hybrid`, uses the JWT bearer handler when a request has an `Authorization: Bearer` header and the cookie otherwise. The SPA always takes the cookie path:

src/AppointMe.Api/Authentication/AuthenticationExtensions.cs

```cs
                .AddPolicyScheme(HybridAuthenticationDefaults.AuthenticationScheme, "Cookie or Bearer", options =>
                {
                    options.ForwardDefaultSelector = context => context.Request.HasBearerTokenHeader()
                        ? JwtBearerDefaults.AuthenticationScheme
                        : CookieAuthenticationDefaults.AuthenticationScheme;
                })
                .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
                {
                    options.Cookie.Name = "appointme.auth";
                    options.Cookie.HttpOnly = true;
                    options.Cookie.SameSite = SameSiteMode.Lax;
                    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
                    options.ExpireTimeSpan = TimeSpan.FromDays(7);
                    options.SlidingExpiration = true;
                })
```

`Authentication:Provider` picks Keycloak (the default) or Entra External ID. When a handler needs the caller’s identity, `HttpIdentityFactory` turns the cookie’s claims into an AppointMe `UserIdentity`, registering the user the first time it sees them. How that identity becomes a per-company principal is on [Identity & principal](/docs/concepts/identity-and-principal/).

### Demo login and 401s

`GET /api/v1/login/demo` signs in the configured demo user without showing the identity provider’s login page. It works only when `Demo:Enabled` is true: `appsettings.Development.json` turns it on, `appsettings.json` leaves it off, and when it’s off the endpoint returns 404.

Under Keycloak the endpoint gets the demo user’s id_token through the password grant, signs in the cookie and redirects to `Frontend:BaseUrl`. The SPA has no button for it, so you open the URL directly.

The axios instance sends the browser to `/auth/login` whenever a response is `401`. The API returns a bare `401` only to Bearer requests. A cookie request without a session gets the default challenge instead, which is a redirect to the identity provider, because `DefaultChallengeScheme` is OpenID Connect.

## Antiforgery: the XSRF cookie and header

**Every POST, PUT, PATCH or DELETE under `/api` without an `Authorization: Bearer` header needs an `X-XSRF-TOKEN` header, signed in or not. Axios adds it from a cookie the API sets on every GET.**

| Name                    | Set by                                    | Script can read it | Role                                 |
| ----------------------- | ----------------------------------------- | ------------------ | ------------------------------------ |
| `appointme.csrf` cookie | ASP.NET Core antiforgery                  | No, HttpOnly       | The cookie half of the token pair    |
| `XSRF-TOKEN` cookie     | `AntiforgeryMiddleware`, on every GET     | Yes                | The request token the SPA sends back |
| `X-XSRF-TOKEN` header   | axios (`xsrfHeaderName`, `withXSRFToken`) | —                  | Checked on every unsafe method       |

src/AppointMe.Api/Antiforgery/AntiforgeryMiddleware.cs

```cs
        if (IsSafeMethod(context.Request.Method))
        {
            // Issued on every GET (not just once) because tokens are bound to the authenticated
            // identity — the SPA's first GET after login/logout refreshes the pair automatically.
            if (HttpMethods.IsGet(context.Request.Method))
            {
                IssueRequestTokenCookie(context);
            }


            await next(context);
            return;
        }
        // …
    // Bearer requests carry no ambient credential, so they cannot be forged cross-site;
    // only cookie-backed requests need the token pair. Non-/api surfaces protect themselves
    // (Hangfire ships its own antiforgery, the OIDC callback is covered by state/correlation).
    private static bool RequiresValidation(HttpRequest request) =>
        request.Path.StartsWithSegments(ApiPrefix) && !request.HasBearerTokenHeader();
```

A failed check returns `400` with the ProblemDetails title `Antiforgery Validation Error`. The SPA’s first request, `GET /api/v1/me`, already receives a fresh pair, so you don’t fetch a token yourself. Any call that goes through the generated hooks or `axiosInstance` sends it.

## The current company

**The selected company id lives in `currentCompanyStore`, backed by `localStorage`. The axios mutator reads it on every request, and switching company invalidates every query.**

| Piece                    | Where                                         | Does                                                                                                                                |
| ------------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `currentCompanyStore`    | `components/auth/current-company-store.ts`    | `get`, `set` and `subscribe` over one id, saved as `appointme:current-company-id`                                                   |
| `useCurrentCompanyId()`  | same file                                     | Subscribes a component with `useSyncExternalStore`                                                                                  |
| `CurrentCompanyProvider` | `components/auth/current-company-context.tsx` | Checks the stored id against the companies from `/me` and falls back to the first. It renders nothing until a valid company is set. |
| `useCurrentCompany()`    | `components/auth/use-current-company.ts`      | `{ currentCompany: { companyId, companyName, timeZone }, setCurrentCompany }`                                                       |
| `CompanySelector`        | `app/layouts/header/company-selector.tsx`     | The header dropdown that calls `setCurrentCompany`                                                                                  |

src/AppointMe.Frontend/src/components/auth/current-company-context.tsx

```tsx
    useEffect(() => {
        if (companies?.length && !currentCompany) {
            currentCompanyStore.set(companies[0].companyId);
        }
    }, [companies, currentCompany]);


    const setCurrentCompany = useCallback(
        async (companyId: string) => {
            if (currentCompanyId === companyId) {
                return;
            }


            currentCompanyStore.set(companyId);
            await queryClient.invalidateQueries();
        },
        [currentCompanyId, queryClient],
    );
```

Invalidating with no filter refetches every active query with the new `X-Company-Id`, including the permissions. The company’s `timeZone` drives date display through `useTimeZone()`. The header only states which company the caller wants: the API checks that the user belongs to it, as explained in [Multi-tenancy](/docs/concepts/multi-tenancy/#how-a-request-gets-its-company).

## Onboarding and invitations

**A signed-in user with no company lands on `/onboarding`, or on `/invitations` if someone invited them. The main app opens once `/me` reports a membership.**

src/AppointMe.Frontend/src/app/router/app-shell.tsx

```tsx
const useUserState = (): UserState => {
    const currentUser = useCurrentUser();


    if (!currentUser.isAuthenticated) {
        return 'anonymous';
    }


    if (currentUser.hasMembership) {
        return 'active';
    }


    if (currentUser.hasPendingInvitations) {
        return 'has-invitations';
    }


    return 'needs-onboarding';
};


const stateRoutes: Record<UserState, string> = {
    anonymous: '/auth/login',
    'needs-onboarding': '/onboarding',
    'has-invitations': '/invitations',
    active: '/',
};
```

| State              | Route          | What the page does                                                                                                              |
| ------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `has-invitations`  | `/invitations` | Lists pending invitations. Accepting one calls `POST /api/v1/invitations/{id}/accept`, invalidates every query and goes to `/`. |
| `needs-onboarding` | `/onboarding`  | `OnboardingForm` posts a company name and time zone to `POST /api/v1/onboarding`, invalidates `/me` and goes to `/`             |
| `active`           | `/`            | Redirects to `/appointments`. `CurrentCompanyProvider` keeps the stored company if it’s valid, or selects the first.            |

A new user reaches onboarding from signup: `/auth/signup` posts to `POST /api/v1/signup` and shows `/auth/verify-email/:email`, and the link in the email returns through `/auth/login?returnUrl=/onboarding`. “Continue with Google” goes to `/auth/login?provider=google&returnUrl=/onboarding`. The user who creates a company gets the `Owner` and `Staff` roles.

## Permission-gated UI

**`UserAccessProvider` loads the caller’s roles and permissions for the current company from `GET /api/v1/me/access`. `Can` and `usePermission` hide what they can’t use, and the API still checks every call.**

src/AppointMe.Frontend/src/components/auth/use-permission.ts

```ts
export const hasPermission = (granted: readonly string[], required: Permission | Permission[]): boolean =>
    Array.isArray(required) ? required.some(permission => granted.includes(permission)) : granted.includes(required);


export const usePermission = (permission: Permission | Permission[]) => {
    const { permissions } = useUserAccess();
    return hasPermission(permissions, permission);
};
```

An array means “any of these”. `<Can permission fallback>` wraps the same check around children:

src/AppointMe.Frontend/src/app/customers/customers.tsx

```tsx
                        <Can permission='customers:create'>
                            <Button asChild size='sm'>
                                <Link to='/customers/new' aria-label='Add customer'>
                                    <PlusIcon />
                                    <span className='hidden sm:inline'>Add customer</span>
                                </Link>
                            </Button>
                        </Can>
```

| Tool                                | Use it for                                                                                                                 |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `<Can permission="…" fallback={…}>` | Hiding a button, a menu item or a whole page. `Dashboard` renders a “Dashboard access is limited” state as its `fallback`. |
| `usePermission(…)`                  | A boolean for logic, such as `enabled` on a query                                                                          |
| `useUserAccess()`                   | The raw `{ companyId, roles, permissions }`                                                                                |
| `permission` on a `NavItem`         | Hiding a sidebar entry in `app/layouts/sidebar/nav-data.ts`                                                                |
| `superAdminOnly` on a `NavItem`     | Showing an entry only when `/me` says `isSuperAdmin`                                                                       |

The permission strings are typed. The API writes every permission code into the OpenAPI document, and orval turns them into a `Permission` union, so a typo in `permission='customer:create'` fails the type-check:

src/AppointMe.Frontend/src/api/client.schemas.ts

```ts
export type Permission = (typeof Permission)[keyof typeof Permission];


export const Permission = {
    'permissions:view': 'permissions:view',
    // …
    'customersstatistics:view': 'customers.statistics:view',
    // …
} as const;
```

Routes aren’t gated. Hiding a sidebar entry doesn’t stop someone typing the URL, so gate the page itself with `Can` and a `fallback`. Where permissions come from is on [Permissions](/docs/concepts/permissions/).

## Logout

**Logout is a top-level form POST to `/api/v1/logout`, so the browser follows the identity provider’s end-session redirects as a normal navigation.**

src/AppointMe.Frontend/src/lib/logout.ts

```ts
export const logout = () => {
    const form = document.createElement('form');
    form.method = 'post';
    form.action = '/api/v1/logout';
    document.body.appendChild(form);
    form.submit();
};
```

src/Identity/AppointMe.Identity/Logout/LogoutEndpoint.cs

```cs
    private static async Task Logout(HttpContext context, IOptions<FrontendOptions> frontendOptions)
    {
        await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
        {
            RedirectUri = frontendOptions.Value.BaseUrl.ToString()
        });
    }
```

The endpoint is `RequireAuthorization()` with the default policy, which asks only for a signed-in user and skips the fallback policy’s registered-user check. It clears the cookie, then signs out of the OIDC scheme, which redirects to the provider’s end-session endpoint and back to `Frontend:BaseUrl`.

The SPA doesn’t clear `currentCompanyStore`. The next user in the same browser still gets their own first company, because `CurrentCompanyProvider` ignores an id they don’t belong to.

## Gotchas

| Symptom                                                              | Cause                                                                                                                                                                                       | Fix                                                                                                                                                    |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| “Log out” answers `400 Antiforgery Validation Error`                 | `lib/logout.ts` submits a plain form with neither the `X-XSRF-TOKEN` header nor a `__RequestVerificationToken` field, and `AntiforgeryMiddleware` checks every non-Bearer POST under `/api` | ASP.NET Core also accepts the token as a `__RequestVerificationToken` form field. Add a hidden input with the `XSRF-TOKEN` cookie’s value to the form. |
| A `fetch` you wrote fails with `400 Antiforgery Validation Error`    | It bypassed axios, so it sent no `X-XSRF-TOKEN`                                                                                                                                             | Use a generated hook or `axiosInstance`.                                                                                                               |
| A hidden page still opens from its URL                               | `Can` and nav `permission` only hide UI                                                                                                                                                     | Wrap the page in `Can` with a `fallback`. The API still returns `403`.                                                                                 |
| A `403` or `404` shows no toast                                      | The global toast covers only network errors and `5xx`                                                                                                                                       | Handle `4xx` where you call the hook, for example `isError` and `ErrorWidget`.                                                                         |
| A list shows the previous company’s rows for a moment after a switch | Query keys don’t include the company, and `invalidateQueries()` keeps cached data while it refetches                                                                                        | Use `queryClient.resetQueries()` in `setCurrentCompany` if pages should show their loading state instead.                                              |

### Why a cookie session instead of tokens in the browser?

With an HttpOnly cookie, no script in the page can read the session, so an XSS bug can’t walk off with a token. The SPA has no token storage, no refresh logic and no `Authorization` header to manage. The API keeps only the id_token, inside the cookie, for logout’s `id_token_hint`.

The price of an ambient credential is CSRF, which is why the antiforgery pair exists. Bearer tokens stay available through the `Hybrid` scheme for clients that aren’t browsers, and those requests skip the antiforgery check because nothing sends them automatically.

## Next

* [Multi-tenancy](/docs/concepts/multi-tenancy/) (Concept)
* [Identity & principal](/docs/concepts/identity-and-principal/) (Concept)
* [Permissions](/docs/concepts/permissions/) (Concept)
