Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

examples: add gatekeeper-auth example. #1963

Merged
merged 40 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
46e0d23
example: generate phoenix boilerplate.
thruflo Nov 8, 2024
e132e87
api: Elixir/Phoenix-based gatekeeper and proxy example.
thruflo Nov 11, 2024
b3430ea
docs: copy pass on docstrings.
thruflo Nov 11, 2024
c184468
api: tweak imports for readability.
thruflo Nov 11, 2024
4360a01
tests: remove non-essential test file.
thruflo Nov 11, 2024
475269e
router: fix imports.
thruflo Nov 11, 2024
009298d
auth: use standard `Authorization: Bearer ...` header.
thruflo Nov 11, 2024
383f384
api: mix format.
thruflo Nov 11, 2024
6844910
api: prepare docker release.
thruflo Nov 13, 2024
d3f8a8e
api: simplify the `Item` schema.
thruflo Nov 13, 2024
0047f21
caddy: add Caddy reverse proxy option.
thruflo Nov 13, 2024
776230d
docs: README and docker-compose with options.
thruflo Nov 13, 2024
8d8757c
docs: make the dark flow diagramme less neon.
thruflo Nov 13, 2024
c9681bf
docs: fix link.
thruflo Nov 13, 2024
025c6bb
docs: tidy up README.
thruflo Nov 13, 2024
bc45f43
caddy: tabs and spaces o_O.
thruflo Nov 13, 2024
db6a02c
caddy: use a local docker build.
thruflo Nov 13, 2024
68810c4
caddy: fix and simplify the definition match expression.
thruflo Nov 13, 2024
0b38f7f
docs: fix Caddy port in example usage.
thruflo Nov 13, 2024
be2112f
config: rename the auth secret to `AUTH_SECRET`.
thruflo Nov 13, 2024
68de2f1
edge: add Supabase Edge Function proxy.
thruflo Nov 13, 2024
09decf5
docs: fix tip syntax.
thruflo Nov 13, 2024
00c85d5
docs: try that again.
thruflo Nov 13, 2024
07a48d4
docs: tidy up the api README.
thruflo Nov 13, 2024
7c1ed83
docs: add code pointers to api README.
thruflo Nov 13, 2024
4ab286c
docs: api comment tweaks.
thruflo Nov 13, 2024
02c00a2
api: capitalise `Authorization`.
thruflo Nov 13, 2024
bf66b6f
api: slightly clearer variable names.
thruflo Nov 13, 2024
a4e5159
docs: hyperlink `jq`.
thruflo Nov 13, 2024
8431ce9
docs: fix example SQL insert to use simplified schema.
thruflo Nov 13, 2024
46140f8
docs: tweak edge function intro copy.
thruflo Nov 13, 2024
ae83170
docs: add package.json.
thruflo Nov 13, 2024
478dae9
pnpm: update lock file.
thruflo Nov 13, 2024
69ff01c
docs: clearer curl examples.
thruflo Nov 13, 2024
ff8f192
docs: clarify shape claim wording.
thruflo Nov 14, 2024
594deb3
docs: clarify that the response goes through the proxy.
thruflo Nov 14, 2024
684ecba
docs: add psql to (optional) pre-reqs.
thruflo Nov 14, 2024
bccb1e4
api: return 401 when auth header is missing or invalid.
thruflo Nov 14, 2024
8c20344
docs: higher-level.
thruflo Nov 14, 2024
2f0c924
docs: address review comments.
thruflo Nov 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/gatekeeper-auth/.env.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ELECTRIC_PROXY_URL=http://localhost:8080
1 change: 1 addition & 0 deletions examples/gatekeeper-auth/.env.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ELECTRIC_PROXY_URL=http://localhost:8000
335 changes: 335 additions & 0 deletions examples/gatekeeper-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@

# Electric Gatekeeper Auth Example

This example demonstrates a number of ways of implementing the [Gatekeeper auth pattern](https://electric-sql.com/docs/guides/auth#gatekeeper) for securing access to the [Electric sync service](https://electric-sql.com/product/sync).

It includes:

- an [`./api`](./api) service for generating auth tokens
- three options for validating those auth tokens when proxying requests to Electric:
- [`./api`](./api) the API itself
- [`./caddy`](./caddy) a Caddy web server as a reverse proxy
- [`./edge`](./edge) an edge function that you can run in front of a CDN


## How it works

There are two steps to the gatekeeper pattern:

1. first a client posts authentication credentials to a gatekeeper endpoint to generate an auth token
2. the client then makes requests to Electric via an authorising proxy that validates the auth token against the shape request

The auth token can be *shape-scoped* (i.e.: can include a claim containing the shape definition). This allows the proxy to authorise a shape request by comparing the shape claim signed into the token with the [shape defined in the request parameters](https://electric-sql.com/docs/quickstart#http-api). This allows you to:

- keep your main authorisation logic in your API (in the gatekeeper endpoint) where it's natural to do things like query the database and call external authorisation services; and to
- run your authorisation logic *once* when generating a token, rather than on the "hot path" of every shape request in your authorising proxy

### Implementation

The core of this example is an [Elixir/Phoenix](https://www.phoenixframework.org) web application in [`./api`](./api). This exposes (in [`api_web/router.ex`](./api/lib/api_web/router.ex)):

1. a gatekeeper endpoint at `POST /gatekeeper/:table`
2. a proxy endpoint at `GET /proxy/v1/shape`

<a href="./docs/img/gatekeeper-flow.jpg" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/img/gatekeeper-flow.dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./docs/img/gatekeeper-flow.png" />
<img alt="Gatekeeper flow diagramme" src="./docs/img/gatekeeper-flow.jpg"
width="100%"
/>
</picture>
</a>
thruflo marked this conversation as resolved.
Show resolved Hide resolved

#### Gatekeeper endpoint

1. the user makes a `POST` request to `POST /gatekeeper/:table` with some authentication credentials and a shape definition in the request parameters; the gatekeeper is then responsible for authorising the user's access to the shape
2. if access is granted, the gatekeeper generates a shape-scoped auth token and returns it to the client
3. the client can then use the auth token when connecting to the Electric HTTP API, via the proxy endpoint

#### Proxy endpoint

4. the proxy validates the JWT auth token and verifies that the shape definition in the token matches the shape being requested; if so it reverse-proxies the request onto Electric
5. Electric then handles the request as normal
6. sending a response back *through the proxy* to the client
7. the client can then process the data and make additional requests using the same auth token (step 3); if the auth token expires or is rejected, the client starts again (step 1).


## How to run

There are three ways to run this example:

1. with the [API as both gatekeeper and proxy](#1-api-as-gatekeeper-and-proxy)
2. with the [API as gatekeeper and Caddy as the proxy](#2-caddy-as-proxy)
3. with the [API as gatekeeper and an edge function as the proxy](#3-edge-function-as-proxy)

It makes sense to run through these in order.

### Pre-reqs

You need [Docker Compose](https://docs.docker.com/compose/) and [curl](https://curl.se). We also (optionally) use [`psql`](https://www.postgresql.org/docs/current/app-psql.html) and pipe into [`jq`](https://jqlang.github.io/jq/) for JSON formatting.

The instructions below all use the same [`./docker-compose.yaml`](./docker-compose.yaml) file in this folder. With a different set of services and environment variables.

> [!TIP]
> All of the configurations are based on running Postgres and Electric. This is handled for you by the `./docker-compose.yaml`. However, if you're unfamiliar with how Electric works, it may be useful to go through the [Quickstart](https://electric-sql.com/docs/quickstart) and [Installation](https://electric-sql.com/docs/guides/installation) guides.

### 1. API as gatekeeper and proxy

Build the local API image:

```shell
docker compose build api
```

Run `postgres`, `electric` and the `api` services:

```console
$ docker compose up postgres electric api
...
gatekeeper-api-1 | 10:22:20.951 [info] == Migrated 20241108150947 in 0.0s
gatekeeper-api-1 | 10:22:21.453 [info] Running ApiWeb.Endpoint with Bandit 1.5.7 at :::4000 (http)
gatekeeper-api-1 | 10:22:21.455 [info] Access ApiWeb.Endpoint at http://localhost:4000
```

In a new terminal, make a `POST` request to the gatekeeper endpoint:

```console
$ curl -sX POST "http://localhost:4000/gatekeeper/items" | jq
{
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJKb2tlbiIsImV4cCI6MTczMTUwMjM2OSwiaWF0IjoxNzMxNDk1MTY5LCJpc3MiOiJKb2tlbiIsImp0aSI6IjMwM28zYmx0czN2aHYydXNiazAwMDJrMiIsIm5iZiI6MTczMTQ5NTE2OSwic2hhcGUiOnsibmFtZXNwYWNlIjpudWxsLCJ0YWJsZSI6Iml0ZW1zIiwid2hlcmUiOm51bGwsImNvbHVtbnMiOm51bGx9fQ.8UZehIWk1EDQ3dJ4ggCBNkx9vGudfrD9appqs8r6zRI"
},
"url": "http://localhost:4000/proxy/v1/shape",
"table": "items"
}
```

You'll see that the response contains:

- the proxy `url` to make shape requests to (`http://localhost:4000/proxy/v1/shape`)
- the request parameters for the shape we're requesting, in this case `"table": "items"`
- an `Authorization` header, containing a `Bearer <token>`

Copy the auth token and set it to an env var:

```shell
export AUTH_TOKEN="<token>"
```

First let's make a `GET` request to the proxy endpoint *without* the auth token. It will be rejected with a `403` status:

```console
$ curl -sv "http://localhost:4000/proxy/v1/shape?table=items&offset=-1"
...
< HTTP/1.1 401 Unauthorized
...
```

Now let's add the authorization header. The request will be successfully proxied through to Electric:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:4000/proxy/v1/shape?table=items&offset=-1"
...
< HTTP/1.1 200 OK
...
```

However if we try to request a different shape (i.e.: using different request parameters), the request will not match the shape signed into the auth token claims and will be rejected:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:4000/proxy/v1/shape?table=items&offset=-1&where=true"
...
< HTTP/1.1 403 Forbidden
...
```

Note that we got an empty response when successfully proxied through to Electric above because there are no `items` in the database. If you like, you can create some, e.g. using `psql`:

```console
$ psql "postgresql://postgres:password@localhost:54321/electric?sslmode=disable"
thruflo marked this conversation as resolved.
Show resolved Hide resolved
psql (16.4)
Type "help" for help.

electric=# \d
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+----------
public | items | table | postgres
public | schema_migrations | table | postgres
(2 rows)

electric=# select * from items;
id | value | inserted_at | updated_at
----+-------+-------------+------------
(0 rows)

electric=# insert into items (id) values (gen_random_uuid());
INSERT 0 1
electric=# \q
```

Now re-run the successful request and you'll get data:

```console
$ curl -s --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:3000/v1/shape?table=items&offset=-1" | jq
[
{
"key": "\"public\".\"items\"/\"b702e58e-9364-4d54-9360-8dda20cb4405\"",
"value": {
"id": "b702e58e-9364-4d54-9360-8dda20cb4405",
"value": null,
"inserted_at": "2024-11-13 10:45:33",
"updated_at": "2024-11-13 10:45:33"
},
"headers": {
"operation": "insert",
"relation": [
"public",
"items"
]
},
"offset": "0_0"
}
]
```

So far we've shown things working with Electric's lower-level [HTTP API](https://electric-sql.com/docs/api/http). You can also setup the [higher-level clients](https://electric-sql.com/docs/api/clients/typescript) to use an auth token. See the [auth guide](https://electric-sql.com/docs/guides/auth) for more details.

### 2. Caddy as proxy

Build the local docker images:

```shell
docker compose build api caddy
```

Run `postgres`, `electric`, `api` and `caddy` services with the `.env.caddy` env file:

```shell
docker compose --env-file .env.caddy up postgres electric api caddy
```

As above, use the gatekeeper endpoint to generate an auth token. Note that the `url` in the response data has changed to point to Caddy:

```console
$ curl -sX POST "http://localhost:4000/gatekeeper/items" | jq
{
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJKb2tlbiIsImV4cCI6MTczMTUwNDUxNCwiaWF0IjoxNzMxNDk3MzE0LCJpc3MiOiJKb2tlbiIsImp0aSI6IjMwM283OGd1cWIxZ240ODhmazAwMDJnNCIsIm5iZiI6MTczMTQ5NzMxNCwic2hhcGUiOnsibmFtZXNwYWNlIjpudWxsLCJ0YWJsZSI6Iml0ZW1zIiwid2hlcmUiOm51bGwsImNvbHVtbnMiOm51bGx9fQ.EkSj-ro9-3chGyuxlAglOjo0Ln8t4HLVLQ4vCCNjMCY"
},
"url": "http://localhost:8080/v1/shape",
"table": "items"
}
```

Copy the auth token and set it to an env var:

```shell
export AUTH_TOKEN="<token>"
```

An unauthorised request to Caddy will get a 401:

```console
$ curl -sv "http://localhost:8080/v1/shape?table=items&offset=-1"
...
< HTTP/1.1 401 Unauthorized
< Server: Caddy
...
```

An authorised request for the correct shape will succeed:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:8080/v1/shape?table=items&offset=-1"
...
< HTTP/1.1 200 OK
...
```

Caddy validates the shape request against the shape definition signed into the auth token. So an authorised request *for the wrong shape* will fail:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:8080/v1/shape?table=items&offset=-1&where=true"
...
< HTTP/1.1 403 Forbidden
...
```

Take a look at the [`./caddy/Caddyfile`](./caddy/Caddyfile) for more details.
thruflo marked this conversation as resolved.
Show resolved Hide resolved

### 3. Edge function as proxy

Electric is [designed to run behind a CDN](https://electric-sql.com/docs/api/http#caching). This makes sync faster and more scalable. However, it means that if you want to authorise access to the Electric API using a proxy, you need to run that proxy in-front-of the CDN.

You can do this with a centralised cloud proxy, such as an API endpoint deployed as part of a backend web service. Or a reverse-proxy like Caddy that's deployed next to your Electric service. However, running these in front of a CDN from a central location reduces the benefit of the CDN &mdash; adding latency and introducing a bottleneck.

It's often better (faster, more scalable and a more natural topology) to run your authorising proxy at the edge, between your CDN and your user. The gatekeeper pattern works well for this because it minimises both the logic that your edge proxy needs to perform and the network access and credentials that it needs to be granted.

The example in the [`./edge`](./edge) folder contains a small [Deno HTTP server](https://docs.deno.com/runtime/fundamentals/http_server/) in the [`index.ts`](./edge/index.ts) file that's designed to work as a [Supabase Edge Function](https://supabase.com/docs/guides/functions/quickstart). See the README in the folder for more information about deploying to Supabase.

Here, we'll run it locally using Docker in order to demonstrate it working with the other services:

```shell
docker compose --env-file .env.edge up postgres electric api edge
```

Hit the gatekeeper endpoint to get an auth token:

```console
$ curl -sX POST "http://localhost:4000/gatekeeper/items" | jq
{
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJKb2tlbiIsImV4cCI6MTczMTUyNDQ1OSwiaWF0IjoxNzMxNTE3MjU5LCJpc3MiOiJKb2tlbiIsImp0aSI6IjMwM3BiaGdob2phcW5pYnE4YzAwMDAwMiIsIm5iZiI6MTczMTUxNzI1OSwic2hhcGUiOnsibmFtZXNwYWNlIjpudWxsLCJ0YWJsZSI6Iml0ZW1zIiwid2hlcmUiOm51bGwsImNvbHVtbnMiOm51bGx9fQ.dNAhTVEUtWGjAoX7IbwX1ccpwZP5sUYTIiTaJnSmaTU"
},
"url": "http://localhost:8000/v1/shape",
"table": "items"
}
```

Copy the auth token and set it to an env var:

```shell
export AUTH_TOKEN="<token>"
```

An unauthorised request to the edge-function proxy will get a 401:

```console
$ curl -sv "http://localhost:8000/v1/shape?table=items&offset=-1"
...
< HTTP/1.1 401 Unauthorized
...
```

An authorised request for the correct shape will succeed:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:8000/v1/shape?table=items&offset=-1"
...
< HTTP/1.1 200 OK
...
```

An authorised request for the wrong shape will fail:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
"http://localhost:8000/v1/shape?table=items&offset=-1&where=true"
...
< HTTP/1.1 403 Forbidden
...
```

## More information

See the [Auth guide](https://electric-sql.com/docs/guides/auth).

If you have any questions about this example please feel free to [ask on Discord](https://discord.electric-sql.com).
45 changes: 45 additions & 0 deletions examples/gatekeeper-auth/api/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file

.dockerignore

# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs

# Common development/test artifacts
/cover/
/doc/
/test/
/tmp/
.elixir_ls

# Mix artifacts
/_build/
/deps/
*.ez

# Generated on crash by the VM
erl_crash.dump

# Static artifacts - These should be fetched and built inside the Docker image
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json
5 changes: 5 additions & 0 deletions examples/gatekeeper-auth/api/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
]
Loading
Loading