# Fine-Grained Proxy (FGP)

> Stateless, API-agnostic HTTP proxy that adds fine-grained tokens (scoped by
> HTTP method, path and request body) in front of any API. No storage: the
> target, credentials, scopes and TTL live encrypted inside the token itself.

FGP sits between a caller and a target API and holds no database: every proxied
request carries an encrypted blob holding the target URL, the upstream
credentials, the allowed scopes and an expiry. It is decryptable only with a
client key that FGP never stores, combined with a server salt. Access is
deny-all: a request is forwarded only if it matches a declared scope.

FGP is a transparent proxy. Any HTTP response actually received from the target
API is forwarded unchanged (status, body, headers), except `Set-Cookie` (the
proxy is stateless), `Transfer-Encoding` (hop-by-hop), and, only when the runtime
already decoded a gzip or br body before FGP received it, the now-stale
`Content-Encoding` and `Content-Length`. Errors produced by FGP itself use the
JSON shape `{"error": "...", "message": "..."}`. Every response carries the
`X-FGP-Source` header: `upstream` when the payload comes from the target API,
`proxy` when FGP produced it. Use that header to decide who to blame and whether
retrying makes sense. Redirects are not followed: a 3xx reaches the caller
unchanged, `Location` included. A blob is bound to one target host, so following
one would silently send the upstream credentials to a host nobody scoped.

Calling a proxied endpoint. Two equivalent transports:

- URL mode: `https://fgp-proxy.lsagetlethias.deno.net/{blob}/{path}`, the blob is the first path segment.
- Header mode: `https://fgp-proxy.lsagetlethias.deno.net/{path}` with the blob in the `X-FGP-Blob` header.
  Preferred, because some infrastructures cap a URL segment at 255 characters.

Both modes require the `X-FGP-Key` header holding the client key. Without it the
blob is useless. A blob is capped at 4096 base64url characters.

Scopes. A scope is either the string form `METHOD:PATH` or a structured object.
Method accepts `*` or a pipe-separated list. Path accepts `*` as a wildcard
matching at least one character:

```
GET:/v1/apps/*              allows GET /v1/apps/my-app and deeper paths
GET|POST:/v1/apps/*         two methods on the same pattern
POST:/v1/apps/my-app/scale  exact path, exact method
*:*                         everything, use with a short TTL only
```

By default a scope constrains the method and the path only: a scope on
`POST:/v1/apps/my-app/scale` also allows `POST /v1/apps/my-app/scale?force=true`.
A scope constrains its query only when it declares `queryFilters` (see below).
Declaring a scope whose pattern carries a `?` is rejected: the pattern never
carries the query, `queryFilters` does.

Authentication modes, set in the `auth` field of the blob:

- `bearer`: sends `Authorization: Bearer {token}`.
- `basic`: sends `Authorization: Basic base64(":" + token)`.
- `scalingo-exchange`: exchanges a Scalingo account token for a bearer, then
  sends it. Cached in memory for 55 minutes.
- `header:{name}`: sends `{name}: {token}`, for APIs that do not use
  `Authorization`.
- `{"type": "headers", "headers": [{"name": "...", "value": "..."}]}`: sends
  several authentication headers at once, up to 8. A single entry is normalized
  back to the `header:{name}` form.
- `{"type": "scalingo-addon", "app": "...", "addonId": "..."}`: obtains a
  one-hour Scalingo database addon token and sends it as a bearer. Exactly one
  database per blob.

Auth headers from the blob are applied after the caller's own, which can never
override or neutralize them. The caller's `Authorization`, `Cookie` and
`Proxy-Authorization` are stripped before forwarding: relaying them would
sidestep the scope model. An API needing a second credential uses the `headers`
mode. Hop-by-hop and forwarding headers are stripped too; everything else passes.

Body filters restrict the JSON body of POST, PUT and PATCH requests. A scope
entry carries `bodyFilters`, each with an `objectPath` (dot-path, 6 segments
max) and an `objectValue` array. All filters of a scope must match (AND); the
values inside one `objectValue` are alternatives (OR). Available value types:

- `{"type": "any", "value": X}`: strict equality, JSON type included.
- `{"type": "wildcard"}`: the field must exist, any value.
- `{"type": "stringwildcard", "value": "prefix/*"}`: glob on a string.
- `{"type": "regex", "value": "^v\\d+$"}`: regular expression on a string.
- `{"type": "not", "value": {...}}`: negation of a single condition.
- `{"type": "and", "value": [{...}, {...}]}`: conjunction, at least 2 entries.

Query filters restrict query parameters, on any method including GET. A scope
entry carries `queryFilters`, each with a `param` (exact name, case sensitive,
no dot-path), a `values` array using the same types as above, and an optional
`required` (default false). Rules:

- **Deny by default**: once a scope declares one query filter, any parameter of
  the request covered by no filter fails that scope. It is a property of the
  scope, and `required: false` never relaxes it.
- `required: true` also fails the scope when the parameter is absent.
- A repeated parameter passes only if every occurrence matches `values`, up to
  4 occurrences when the filter holds a `regex` at any depth, 64 otherwise.
- `any` accepts a string only, at any depth: a query value is always a string.
- A blob with a non-empty `queryFilters` is version 5, and an older proxy
  rejects it rather than serving it without the constraint.

Error codes produced by FGP on the proxy route (`X-FGP-Source: proxy`):

- 400 `invalid_request`: the proxy path has fewer than 2 segments.
- 400 `invalid_auth_mode`: the blob declares an auth mode this instance ignores.
- 400 `invalid_body`: body filters are required but the body is not valid JSON.
- 400 `unsupported_regex`: a scope regex falls outside the accepted dialect.
- 401 `missing_key`: the `X-FGP-Key` header is absent.
- 401 `invalid_credentials`: wrong client key, or corrupted or malformed blob.
- 403 `scope_denied`: no scope matches the method, path or body.
- 403 `target_forbidden`: the target host is not a public address.
- 410 `token_expired`: the blob TTL has elapsed.
- 413 `payload_too_large`: the request body exceeds the inspection limit.
- 414 `blob_too_large`: the blob exceeds 4096 characters.
- 500 `internal_error`: unexpected proxy failure.
- 502 `upstream_unreachable`: the target API could not be reached at all.
- 502 `auth_exchange_failed`: the Scalingo token exchange was refused.
- 502 `auth_addon_failed`: no Scalingo database token could be obtained.

Anything else is an upstream status forwarded verbatim with
`X-FGP-Source: upstream`, including 401, 403, 429 and 5xx. Do not read those as
proxy failures.

Generating a proxied URL, with the target credentials and the scopes:

```
curl -X POST https://fgp-proxy.lsagetlethias.deno.net/api/generate \
  -H "Content-Type: application/json" \
  -d '{
    "token": "sk-live-xxxxxxxx",
    "target": "https://api.example.com",
    "auth": "bearer",
    "scopes": ["GET:/v2/resources/*"],
    "ttl": 3600
  }'
```

The response is `{"url": "...", "key": "...", "blob": "..."}`. The key is returned
once and never stored: losing it makes the blob unusable. An optional `key` field
in the request lets a caller supply its own client key, 24 to 256 printable ASCII
characters without spaces.

Every `/api/*` request that carries a body must set `Content-Type: application/json`.
Anything else is rejected with 415 `unsupported_media_type` before the body is even
read, a different signal from 400 `invalid_body`: 415 means the format itself could
not be read, 400 means the JSON was read and rejected.

Calling through the proxy, blob in the URL then blob in a header (recommended):

```
curl -H "X-FGP-Key: <key>" https://fgp-proxy.lsagetlethias.deno.net/<blob>/v2/resources/42
curl -H "X-FGP-Key: <key>" -H "X-FGP-Blob: <blob>" https://fgp-proxy.lsagetlethias.deno.net/v2/resources/42
```

## Documentation

- [OpenAPI spec](https://fgp-proxy.lsagetlethias.deno.net/api/openapi.json): machine-readable API contract
- [Swagger UI](https://fgp-proxy.lsagetlethias.deno.net/api/docs): interactive API documentation

## Resources

- [Configuration UI](https://fgp-proxy.lsagetlethias.deno.net/): build a proxied URL, test scopes, share a config
- [README](https://github.com/lsagetlethias/fine-grained-proxy): project overview and self-hosting
