Skip to content

Commit

Permalink
Feat: add Auth example & guide (#1566)
Browse files Browse the repository at this point in the history
  • Loading branch information
KyleAMathews and kevin-dp authored Aug 27, 2024
1 parent 60c5069 commit b272d3f
Show file tree
Hide file tree
Showing 23 changed files with 677 additions and 1 deletion.
3 changes: 2 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export default defineConfig({
// { text: 'Deployment', link: '/guides/deployment' },
// { text: 'Writing clients', link: '/guides/write-your-own-client' },
{ text: 'Telemetry', link: '/guides/telemetry' },
{ text: 'Local Development', link: '/guides/local-development' }
{ text: 'Local Development', link: '/guides/local-development' },
{ text: 'Auth', link: '/guides/authentication-and-authorization' }
]
},
{
Expand Down
109 changes: 109 additions & 0 deletions docs/guides/authentication-and-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
outline: deep
---

# Authentication and Authorization

Most sync engines require you to use their APIs for authentication and authorization.

But as [Electric is built on the standard HTTP protocol](/api/http), you can handle
auth for Electric exactly the same as you do the rest of the (HTTP) API calls in your app.

At a high level the pattern for auth is:

1. Add an `Authorization` header to authenticate the client when requesting [shape](/guides/shapes) data.
2. The API uses the header to check that the client exists and has access to the requested data.
3. If not, it returns a 401 or 403 status to tell the client it doesn't have access.
4. If the client does have access, the API then requests the shape data from Electric and streams that back to the client.

In the app, you pass a `fetchWrapper` to the Electric client which adds your `Authorization` header when
Electric requests shape data.

## Sample code

In the client:

```tsx
const fetchWrapper = async (...args: Parameters<typeof fetch>) => {
const user = loadCurrentUser()
const modifiedArgs = [...args]
const headers = new Headers(
(modifiedArgs[1] as RequestInit)?.headers || {}
)

// Set authorization token
headers.set(`Authorization`, `Bearer ${user.token}`)

modifiedArgs[1] = { ...(modifiedArgs[1] as RequestInit), headers }
const response = await fetch(
...(modifiedArgs as [RequestInfo, RequestInit?])
)
return response
}

const usersShape = (): ShapeStreamOptions => {
return {
url: new URL(`/api/shapes/users`, window.location.origin).href,
fetchClient: fetchWrapper,
}
}

export default function ExampleComponent () {
const { data: users } = useShape(usersShape())
}
```

Then for the `/api/shapes/users` route:

```tsx
export async function GET(
request: Request,
{ params }: { params: { table: string } }
) {
const url = new URL(request.url)
const { table } = params

// Constuct the upstream URL
const originUrl = new URL(`http://localhost:3000/v1/shape/${table}`)

// Copy over the shape_id & offset query params that the
// Electric client adds so we return the right part of the Shape log.
url.searchParams.forEach((value, key) => {
if ([`shape_id`, `offset`].includes(key)) {
originUrl.searchParams.set(key, value)
}
})

// authentication and authorization
const user = await loadUser(request.headers.get(`authorization`))

// If the user isn't set, return 401
if (!user) {
return new Response(`user not found`, { status: 401 })
}

// Only query data the user has access to unless they're an admin.
if (!user.roles.includes(`admin`)) {
originUrl.searchParams.set(`where`, `"org_id" = ${user.org_id}`)
}

// When proxying long-polling requests, content-encoding &
// content-length are added erroneously (saying the body is
// gzipped when it's not) so we'll just remove them to avoid
// content decoding errors in the browser.
//
// Similar-ish problem to https://github.com/wintercg/fetch/issues/23
let resp = await fetch(originUrl.toString())
if (resp.headers.get(`content-encoding`)) {
const headers = new Headers(resp.headers)
headers.delete(`content-encoding`)
headers.delete(`content-length`)
resp = new Response(resp.body, {
status: resp.status,
statusText: resp.statusText,
headers,
})
}
return resp
}
```
1 change: 1 addition & 0 deletions examples/auth/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build/**
41 changes: 41 additions & 0 deletions examples/auth/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
`eslint:recommended`,
`plugin:@typescript-eslint/recommended`,
`plugin:prettier/recommended`,
],
parserOptions: {
ecmaVersion: 2022,
requireConfigFile: false,
sourceType: `module`,
ecmaFeatures: {
jsx: true,
},
},
parser: `@typescript-eslint/parser`,
plugins: [`prettier`],
rules: {
quotes: [`error`, `backtick`],
"no-unused-vars": `off`,
"@typescript-eslint/no-unused-vars": [
`error`,
{
argsIgnorePattern: `^_`,
varsIgnorePattern: `^_`,
caughtErrorsIgnorePattern: `^_`,
},
],
},
ignorePatterns: [
`**/node_modules/**`,
`**/dist/**`,
`tsup.config.ts`,
`vitest.config.ts`,
`.eslintrc.js`,
],
};
10 changes: 10 additions & 0 deletions examples/auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
dist
.env.local

# Turborepo
.turbo

# next.js
/.next/
/out/
next-env.d.ts
5 changes: 5 additions & 0 deletions examples/auth/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
29 changes: 29 additions & 0 deletions examples/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Auth example

Example showing how an API can proxy shape requests and authenticate the client
and either deny access, give full access, or modify the shape request (in this
case, by adding a where clause) so the client sees only data they have permission
to see.

https://github.com/user-attachments/assets/eab62c23-513c-4ed8-a6fa-249b761f8667

## Setup

1. Make sure you've installed all dependencies for the monorepo and built packages

From the root directory:

- `pnpm i`
- `pnpm run -r build`

2. Start the docker containers

`pnpm run backend:up`

3. Start the dev server

`pnpm run dev`

4. When done, tear down the backend containers so you can run other examples

`pnpm run backend:down`
25 changes: 25 additions & 0 deletions examples/auth/app/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.App {
text-align: center;
}

.App-logo {
height: min(160px, 30vmin);
pointer-events: none;
margin-top: min(30px, 5vmin);
margin-bottom: min(30px, 5vmin);
}

.App-header {
background-color: #1c1e20;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: top;
justify-content: top;
font-size: calc(10px + 2vmin);
color: white;
}

.App-link {
color: #61dafb;
}
41 changes: 41 additions & 0 deletions examples/auth/app/Example.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.controls {
margin-bottom: 1.5rem;
}

.button {
display: inline-block;
line-height: 1.3;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
user-select: none;
width: calc(15vw + 100px);
margin-right: 0.5rem !important;
margin-left: 0.5rem !important;
border-radius: 32px;
text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4);
box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px;
background: #1e2123;
border: 2px solid #229089;
color: #f9fdff;
font-size: 16px;
font-weight: 500;
padding: 10px 18px;
}

.item {
display: block;
line-height: 1.3;
text-align: center;
vertical-align: middle;
width: calc(30vw - 1.5rem + 200px);
margin-right: auto;
margin-left: auto;
border-radius: 32px;
border: 1.5px solid #bbb;
box-shadow: rgba(0, 0, 0, 0.3) 1px 2px 8px 0px;
color: #f9fdff;
font-size: 13px;
padding: 10px 18px;
}
27 changes: 27 additions & 0 deletions examples/auth/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import "./style.css"
import "./App.css"
import { Providers } from "./providers"

export const metadata = {
title: `Electric Auth Example`,
description: `Example application showing how to do authentication and authorization with Electric.`,
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<div className="App">
<header className="App-header">
<img src="/logo.svg" className="App-logo" alt="logo" />
<Providers>{children}</Providers>
</header>
</div>
</body>
</html>
)
}
Loading

0 comments on commit b272d3f

Please sign in to comment.