> ## Documentation Index
> Fetch the complete documentation index at: https://playbook.smedleygroup.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication and Authorisation

> The four ways a caller proves who it is, the claims it gets, how tokens are signed, and why authorisation is a separate opt-in step inside every action.

GKL API does not use the standard ASP.NET authentication pipeline. Every request passes through one custom `SecurityMiddleware`, which decides **who** the caller is, and then each action decides for itself **what** that caller may do. Those are two separate mechanisms, and the second one is opt in.

Anyone adding an endpoint needs to understand both. See [The API Surface](/software/developer/estate/systems/gkl/api-surface) for what exists today.

## The Four Ways In

| Credential | Header | Who uses it | Becomes |
| - | - | - | - |
| External API key | `x-api-key` | Callers outside the estate | `EXTERNAL_SERVICE` |
| Internal API key | `x-api-key` | Our own services calling in, including the notification Lambda and the telemetry converter | `INTERNAL_SERVICE` |
| User JWT | `x-api-key` | Signed in customers and operators | Whatever claims the token carries |
| Vivenu HMAC signature | `x-vivenu-signature` | Vivenu webhooks, and nothing else | `WEBHOOK` |

> [!WARNING]
>
> The first three all arrive in the **same header**. `x-api-key` carries either an API key or a bearer token, and the middleware works out which by trying the external key, then the internal key, then validating it as a JWT. So a malformed token and a wrong API key are indistinguishable from the outside, and both come back as a bare 401.

## How A Request Is Authenticated

**1. Anonymous endpoints go straight through**

An action marked `[AllowAnonymous]` skips all of this. There are eight of them, listed in [The API Surface](/software/developer/estate/systems/gkl/api-surface#endpoints-that-need-no-credential).

**2. Webhook endpoints are verified by signature**

An action marked `[HmacVerification]` is checked against `x-vivenu-signature` instead of a key. See below.

**3. Everything else needs the header**

No `x-api-key` means 401 immediately, with the body `Unauthorized`.

**4. The value is tried three ways**

Against the external API key, then the internal API key, then as a JWT. The first match wins and sets the caller's claims. If none match, the response is 401 carrying the token validation message.

> [!NOTE]
>
> Order matters. The two service keys are compared before any token parsing happens, so a service key never pays the cost of a failed JWT decode, and a JWT that happens to equal a service key would be treated as that service.

## Webhook Verification

Vivenu webhooks are the only callers that do not present a key. The middleware computes HMAC SHA256 over the **raw request body**, hex encoded and lowercased, and compares it to the `x-vivenu-signature` header. Request buffering is enabled so the body can be read for hashing and then again by the action.

The signing secret is **per hub region**. It is read from that region's entry in the `REGION_CONFIGURATION` document, selected using the `hubRegion` query string parameter on the request.

> [!WARNING]
>
> If `hubRegion` is missing from the query string the middleware **throws** rather than returning 401, so the caller sees a 500. A webhook registered in Vivenu without the region parameter therefore looks like a server fault rather than a configuration mistake. That is the first thing to check when a newly registered webhook fails.

> [!NOTE]
>
> Because the secret is per region, a webhook is only ever valid for the region named in its own URL. Registering the right URL with the wrong region silently fails signature verification and returns 401.

## The Claims

A caller ends up with a set of claims, and authorisation is expressed entirely in terms of them.

| Claim | Meaning |
| - | - |
| `SUPER_ADMIN` | An operator with full access. Carried by admin accounts |
| `INTERNAL_SERVICE` | Presented the internal API key. Passes almost every check in the API |
| `EXTERNAL_SERVICE` | Presented the external API key |
| `WEBHOOK` | A Vivenu webhook that passed signature verification |
| `BIGQUERY_ADMIN` | May trigger the BigQuery export |
| `TRACK` | Reserved for track side callers |
| `TELEMETRY` | The telemetry converter. Reaches the two endpoints it needs and nothing else |

Alongside those, a token carries a `TYPE` claim and identity claims:

| Claim | Value | Meaning |
| - | - | - |
| `TYPE` | `AUTHENTICATED` | A normal signed in session |
| `TYPE` | `RESET_PASSWORD` | Issued for a password reset link only |
| `TYPE` | `VERIFY_EMAIL` | Issued for an email confirmation link only |
| `Id` | The user or admin ID | Used to prove a caller is acting on its own record |
| `Email` | The user's email | Used by the reset and confirm flows |

> [!NOTE]
>
> The `TYPE` claim is what keeps the emailed links narrow. A password reset token carries `RESET_PASSWORD` and **not** `AUTHENTICATED`, so it satisfies the reset endpoint and fails every endpoint that wants a real session. The same holds for email confirmation. Getting a reset link does not get you an account.

## Tokens

Tokens are issued by `JsonTokenService`, signed with HMAC SHA256, and expire after **one day** by default. The claims come from the user or admin document, plus any special claims the caller adds. Logging in adds `TYPE=AUTHENTICATED`.

> [!WARNING]
>
> **The signing secret is derived from the internal API key.** It is the base64 of the UTF32 bytes of that key, and nothing else. So rotating the internal API key does two things at once: it invalidates **every token already issued**, signing every user out, and it breaks **every internal caller** still using the old key. Those two blast radii are not obviously connected, and nothing in the code hints at it.

### Where The Keys Come From

| Running | Internal key | External key |
| - | - | - |
| In AWS | Secrets Manager, secret named by `AWS_INTERNAL_API_SECRET_NAME`, field `API_KEY` | Secrets Manager, secret named by `AWS_EXTERNAL_API_SECRET_NAME`, field `API_KEY` |
| Locally | `INTERNAL_API_KEY` environment variable | `EXTERNAL_API_KEY` environment variable |

> [!NOTE]
>
> Both are read once and then cached in memory, so a rotated secret is not picked up until the process restarts. On Lambda that means the change lands unevenly as warm containers age out, and for a while some requests will validate against the old key and some against the new.

## Authorisation Is Opt In

There are no `[Authorize]` attributes and no authorisation policies. Instead, each action calls a guard as its **first line**:

```csharp theme={null}
HttpContext.AnyTrueOrUnauthorized([userId.UserIdClaim(), Claims.AuthenticatedClaim], Claims.InternalServiceClaim);
```

The guard takes a set of claims the caller must have **all** of, and one higher claim that overrides them. It passes if either side is satisfied, and otherwise throws `UnauthorizedAccessException`, which the middleware converts to a 401.

Three idioms cover almost every endpoint:

| Idiom | Reads as |
| - | - |
| `([userId.UserIdClaim(), AuthenticatedClaim], InternalServiceClaim)` | This user, about their own record, or an internal service |
| `([SuperAdminClaim, AuthenticatedClaim], InternalServiceClaim)` | A signed in operator, or an internal service |
| `MustBeInternalService()` | Internal callers only |

> [!WARNING]
>
> An action that calls no guard is **authenticated but not authorised**. Any caller holding any valid credential, including any signed in customer, passes. The guard is a line of code that is easy to leave out, and nothing fails if you do.

### What That Means Today

Of **88 endpoints**, 83 are either anonymous by design, HMAC verified, or reach a guard. **Five reach no guard at all:**

| Endpoint | Why it is like that |
| - | - |
| `GET /api/v1/events/{eventId}` | Event browsing for a signed in customer |
| `GET /api/v1/events/bulk/{eventIds}` | As above, in bulk |
| `GET /api/v1/events/{eventId}/info` | Availability, read through to Vivenu |
| `GET /api/v1/events/toDisplay` | The event list for the current hub region |
| `GET /api/v1/checkouts/discount/{code}/{eventId}` | Discount code lookup during checkout |

All five are read paths the booking website needs for any signed in customer, so having no admin guard is consistent with their purpose. There is no endpoint in the API that looks like it lost its guard by accident.

## One Thing Not To Change

`GET /api/v1/configurations/regions` is anonymous, because the hub picker on the booking website needs it before anyone signs in. It returns the `REGION_CONFIGURATION` document, which also holds each region's **webhook HMAC secret, Vivenu API secret and Teams webhook URL**.

What keeps those out of the response is that the DTO mapper is an allowlist. It projects five fields and nothing else: the region ID, currency, display name, whether it is active, and whether to show it on the website.

> [!WARNING]
>
> Adding a field to `ToDto(this RegionConfigurationItem)` publishes it to the internet with no authentication. The neighbouring `GET /api/v1/configurations/all` endpoint is anonymous too, and protects itself by explicitly excluding `REGION_CONFIGURATION` from its results. Both of those protections are one line long and neither is obvious from the endpoint.

## Key Files

All paths are under `GklPlatformAPI/GklPlatform/` in the GKL Platform repository.

| File | Role |
| - | - |
| `GklPlatform.API.Core/Controllers/Auth/SecurityMiddleware.cs` | The whole authentication decision, including HMAC verification |
| `GklPlatform.API.Core/Controllers/Auth/JsonTokenService.cs` | Issuing and validating tokens, and the signing secret |
| `GklPlatform.API.Core/Utils/Claims.cs` | Every claim, and the service claims principals |
| `GklPlatform.API.Core/Utils/SecurityExtensionMethods.cs` | `AnyTrueOrUnauthorized` and `MustBeInternalService` |
| `GklPlatform.Core/Utils/GklEnvironment.cs` | Where the two API keys are read from, and their caching |
| `GklPlatform.API.Core/Utils/GklToDtoMapperExtensionMethods.cs` | The region DTO allowlist |
| `GklPlatform.API.Core/Controllers/CredentialsController.cs` | Register, login, password and email confirmation flows |
| `GklPlatform.API.Core/Controllers/Admin/AdminAuthController.cs` | Operator login and admin accounts |

## Related Documentation

| Page | Relevance |
| - | - |
| [The API Surface](/software/developer/estate/systems/gkl/api-surface) | Every controller, and which claims each area expects |
| [Architecture](/software/developer/estate/systems/gkl/architecture) | The two gateways, and why the notification Lambda calls back in |
| [Hub Regions and Configuration](/software/developer/estate/systems/gkl/hub-regions-and-configuration) | The region configuration these secrets live in |
| [Disabling An Operator Dashboard Admin](/software/developer/estate/systems/gkl/sop-disable-od-admin) | The procedure for an admin account |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.