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

docs: update auth guide with proxy and gatekeeper patterns. #1988

Merged
merged 22 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/thirty-starfishes-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@electric-sql/client": patch
---

Exposed `shape.handle` getter on `Shape` and rename `shapeHandle` to `handle` in the `ShapeStreamOptions`.
29 changes: 0 additions & 29 deletions examples/auth/README.md

This file was deleted.

31 changes: 19 additions & 12 deletions examples/gatekeeper-auth/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

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

It includes:

Expand All @@ -11,6 +11,8 @@ It includes:
- [`./caddy`](./caddy) a Caddy web server as a reverse proxy
- [`./edge`](./edge) an edge function that you can run in front of a CDN

> [!TIP]
> You can see an alternative pattern for auth in the [proxy-auth](../proxy-auth) example.

## How it works

Expand All @@ -19,10 +21,10 @@ 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:
The auth token can be *shape-scoped* (i.e.: can include a claim containing the shape definition). This allows the proxy to authorize 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
- keep your main authorization logic in your API (in the gatekeeper endpoint) where it's natural to do things like query the database and call external authorization services; and to
- run your authorization logic *once* when generating a token, rather than on the "hot path" of every shape request in your authorising proxy

### Implementation

Expand Down Expand Up @@ -232,7 +234,7 @@ Copy the auth token and set it to an env var:
export AUTH_TOKEN="<token>"
```

An unauthorised request to Caddy will get a 401:
An unauthorized request to Caddy will get a 401:

```console
$ curl -sv "http://localhost:8080/v1/shape?table=items&offset=-1"
Expand All @@ -242,7 +244,7 @@ $ curl -sv "http://localhost:8080/v1/shape?table=items&offset=-1"
...
```

An authorised request for the correct shape will succeed:
An authorized request for the correct shape will succeed:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
Expand All @@ -252,7 +254,7 @@ $ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
...
```

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

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
Expand All @@ -266,7 +268,7 @@ Take a look at the [`./caddy/Caddyfile`](./caddy/Caddyfile) for more details.

### 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.
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 authorize 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.

Expand Down Expand Up @@ -299,7 +301,7 @@ Copy the auth token and set it to an env var:
export AUTH_TOKEN="<token>"
```

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

```console
$ curl -sv "http://localhost:8000/v1/shape?table=items&offset=-1"
Expand All @@ -308,7 +310,7 @@ $ curl -sv "http://localhost:8000/v1/shape?table=items&offset=-1"
...
```

An authorised request for the correct shape will succeed:
An authorized request for the correct shape will succeed:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
Expand All @@ -318,7 +320,7 @@ $ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
...
```

An authorised request for the wrong shape will fail:
An authorized request for the wrong shape will fail:

```console
$ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
Expand All @@ -328,6 +330,11 @@ $ curl -sv --header "Authorization: Bearer ${AUTH_TOKEN}" \
...
```

### Example client

See the [./client](./client) folder for an example that uses the [Typescript client]() with gatekeeper and proxy endpoints.


## More information

See the [Auth guide](https://electric-sql.com/docs/guides/auth).
Expand Down
2 changes: 1 addition & 1 deletion examples/gatekeeper-auth/api/lib/api_web/authenticator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule ApiWeb.Authenticator do
request
end

def authorise(shape, request_headers) do
def authorize(shape, request_headers) do
header_map = Enum.into(request_headers, %{})
header_key = String.downcase(@header_name)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule ApiWeb.ProxyController do
defp proxy_request(%{req_headers: headers} = conn) do
conn
|> build_url()
|> Req.get!(headers: headers, into: :self)
|> Req.get!(headers: headers, into: :self, receive_timeout: 30_000)
end

defp build_url(%{path_info: [_prefix | segments], query_string: query}) do
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule ApiWeb.Plugs.Auth.AuthoriseShapeAccess do
defmodule ApiWeb.Plugs.Auth.AuthorizeShapeAccess do
@moduledoc """
This plug allows the dummy user to access any shape.

Expand All @@ -11,7 +11,7 @@ defmodule ApiWeb.Plugs.Auth.AuthoriseShapeAccess do
def init(opts), do: opts

def call(%{assigns: %{current_user: user, shape: shape}} = conn, _opts) do
case is_authorised(user, shape) do
case is_authorized(user, shape) do
true ->
conn

Expand All @@ -22,9 +22,9 @@ defmodule ApiWeb.Plugs.Auth.AuthoriseShapeAccess do
end
end

defp is_authorised(:dummy, _) do
defp is_authorized(:dummy, _) do
true
end

defp is_authorised(_, _), do: false
defp is_authorized(_, _), do: false
end
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ defmodule ApiWeb.Plugs.Auth.VerifyToken do
def init(opts), do: opts

def call(%{assigns: %{shape: shape}, req_headers: headers} = conn, _opts) do
case Authenticator.authorise(shape, headers) do
case Authenticator.authorize(shape, headers) do
{:error, message} when message in [:invalid, :missing] ->
conn
|> send_resp(401, "Unauthorized")
Expand Down
4 changes: 2 additions & 2 deletions examples/gatekeeper-auth/api/lib/api_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule ApiWeb.Router do
plug AssignShape

plug Auth.AuthenticateUser
plug Auth.AuthoriseShapeAccess
plug Auth.AuthorizeShapeAccess
end

pipeline :proxy do
Expand All @@ -22,7 +22,7 @@ defmodule ApiWeb.Router do
pipe_through :api

# The gatekeeper endpoint at `POST /gatekeeper/:table` authenticates the user,
# authorises the shape access, generates a shape-scoped auth token and returns
# authorizes the shape access, generates a shape-scoped auth token and returns
# this along with other config that an Electric client can use to stream the
# shape directly from Electric.
scope "/gatekeeper" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule ApiWeb.AuthenticatorTest do
{:ok, shape} = Shape.from(%{"table" => "foo"})

headers = Authenticator.authenticate_shape(shape, nil)
assert Authenticator.authorise(shape, headers)
assert Authenticator.authorize(shape, headers)
end

test "validate token with params" do
Expand All @@ -27,7 +27,7 @@ defmodule ApiWeb.AuthenticatorTest do
})

headers = Authenticator.authenticate_shape(shape, nil)
assert Authenticator.authorise(shape, headers)
assert Authenticator.authorize(shape, headers)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ defmodule ApiWeb.GatekeeperTest do

{:ok, shape} = Shape.from(%{"table" => table})

assert Authenticator.authorise(shape, headers)
assert Authenticator.authorize(shape, headers)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule ApiWeb.IntegrationTest do
|> post("/gatekeeper/#{table}", where: where)
|> json_response(200)

# Make an authorised shape request.
# Make an authorized shape request.
assert [] =
conn
|> put_req_header("authorization", auth_header)
Expand Down
14 changes: 14 additions & 0 deletions examples/gatekeeper-auth/client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@

# Gatekeeper client

This is a little client app that syncs a shape from Electric using the gatekeeper pattern.

I.e.: if first fetches config, including an auth token, from the gatekeeper. Then it uses the config to connect to Electric via the authorizing proxy.

Note that it will use whichever proxy the API is configured to use (by connecting to the proxy using the url in the gatekeeper response).

## Run locally

```shell
npx tsx index.ts
```
54 changes: 54 additions & 0 deletions examples/gatekeeper-auth/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Shape, ShapeStream } from '@electric-sql/client'

const API_URL = process.env.API_URL || "http://localhost:4000"

interface Definition {
table: string,
where?: string,
columns?: string
}

/*
* Fetch the shape options and start syncing. When new data is recieved,
* log the number of rows. When an auth token expires, reconnect.
*/
async function sync(definition: Definition, handle?: string, offset: string = '-1') {
console.log('sync: ', offset)

const options = await fetchShapeOptions(definition)
const stream = new ShapeStream({...options, handle: handle, offset: offset})
const shape = new Shape(stream)

shape.subscribe(async ({ rows }) => {
if (shape.error && 'status' in shape.error) {
const status = shape.error.status
console.warn('fetch error: ', status)

if (status === 401 || status === 403) {
shape.unsubscribeAll()

return await sync(definition, shape.handle, shape.lastOffset)
}
}
else {
console.log('num rows: ', rows ? rows.length : 0)
}
})
}

/*
* Make a request to the gatekeeper endpoint to get the proxy url and
* auth headers to connect to/with.
*/
async function fetchShapeOptions(definition: Definition) {
const { table, ...params} = definition

const qs = new URLSearchParams(params).toString()
const url = `${API_URL}/gatekeeper/${table}${qs ? '?' : ''}${qs}`

const resp = await fetch(url, {method: "POST"})
return await resp.json()
}

// Start syncing.
await sync({table: 'items'})
2 changes: 1 addition & 1 deletion examples/gatekeeper-auth/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ services:
AUTH_SECRET: "NFL5*0Bc#9U6E@tnmC&E7SUN6GwHfLmY"
DATABASE_URL: "postgresql://postgres:password@postgres:5432/electric?sslmode=disable"
ELECTRIC_URL: "http://electric:3000"
ELECTRIC_PROXY_URL: "${ELECTRIC_PROXY_URL:-http://localhost:3000/proxy}"
ELECTRIC_PROXY_URL: "${ELECTRIC_PROXY_URL:-http://localhost:4000/proxy}"
PHX_HOST: "localhost"
PHX_PORT: 4000
PHX_SCHEME: "http"
Expand Down
8 changes: 4 additions & 4 deletions examples/gatekeeper-auth/docs/NOTES.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
### Seperating the concerns

With the [proxy auth pattern](https://electric-sql.com/docs/guides/auth#proxy), the proxy performs authorisation logic on the shape request path. Performing this logic can be expensive. You may not want to query the database or call an external service every time you make a shape request [1]. It can also be a security concern. Do you want your database exposed to your edge worker?
With the [proxy auth pattern](https://electric-sql.com/docs/guides/auth#proxy-auth), the proxy performs authorization logic on the shape request path. Performing this logic can be expensive. You may not want to query the database or call an external service every time you make a shape request [1]. It can also be a security concern. Do you want your database exposed to your edge worker?

The gatekeeper pattern avoids these concerns by separating the steps of:

1. running authorisation logic to determine whether a user should be able to access a shape
1. running authorization logic to determine whether a user should be able to access a shape
2. authorising a shape request to Electric

Specifically, the gatekeeper endpoint is designed to perform authorisation logic *once* when generating the shape-scoped token. The proxy endpoint can then authorise multiple shape requests by validating the token against the shape definition in the request, without needing to know or do anything else.
Specifically, the gatekeeper endpoint is designed to perform authorization logic *once* when generating the shape-scoped token. The proxy endpoint can then authorize multiple shape requests by validating the token against the shape definition in the request, without needing to know or do anything else.

[1] Proxies can mitigate this in a number of ways, for example with some kind of local cache of client credentials against authorisation state.
[1] Proxies can mitigate this in a number of ways, for example with some kind of local cache of client credentials against authorization state.
13 changes: 10 additions & 3 deletions examples/gatekeeper-auth/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ import jwt from 'jsonwebtoken'
const AUTH_SECRET = Deno.env.get("AUTH_SECRET") || "NFL5*0Bc#9U6E@tnmC&E7SUN6GwHfLmY"
const ELECTRIC_URL = Deno.env.get("ELECTRIC_URL") || "http://localhost:3000"

interface ShapeDefinition {
table: string
columns?: string
namespace?: string
where?: string
}

/**
* Match `GET /v1/shape` requests.
*/
function isGetShapeRequest(method, path) {
function isGetShapeRequest(method: string, path: string) {
return method === 'GET' && path.endsWith('/v1/shape')
}

/**
* Allow requests with a valid JWT in the auth header.
*/
function verifyAuthHeader(headers) {
function verifyAuthHeader(headers: Headers) {
const auth_header = headers.get("Authorization")

if (auth_header === null) {
Expand All @@ -38,7 +45,7 @@ function verifyAuthHeader(headers) {
* Allow requests where the signed `shape` definition in the JWT claims
* matches the shape definition in the request `params`.
*/
function matchesDefinition(shape, params) {
function matchesDefinition(shape: ShapeDefinition, params: URLSearchParams) {
if (shape === null || !shape.hasOwnProperty('table')) {
return false
}
Expand Down
Loading
Loading