Skip to content

Commit

Permalink
[#15] Added helper cookie utils and refactored authStore.model defini…
Browse files Browse the repository at this point in the history
…tion
  • Loading branch information
ganigeorgiev committed Aug 24, 2022
1 parent 4074892 commit 8a7dd2a
Show file tree
Hide file tree
Showing 21 changed files with 1,042 additions and 179 deletions.
244 changes: 222 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ Official JavaScript SDK (browser and node) for interacting with the [PocketBase
- [AuthStore](#authstore)
- [Auto cancellation](#auto-cancellation)
- [Send hooks](#send-hooks)
- [SSR integration](#ssr-integration)
- [Security](#security)
- [Definitions](#definitions)
- [Development](#development)


## Installation

#### Browser (manually via script tag)
### Browser (manually via script tag)

```html
<script src="/path/to/dist/pocketbase.umd.js"></script>
Expand All @@ -31,7 +33,7 @@ _OR if you are using ES modules:_
</script>
```

#### Node.js (via npm)
### Node.js (via npm)

```sh
npm install pocketbase --save
Expand All @@ -58,7 +60,7 @@ const PocketBase = require('pocketbase/cjs')
> // npm install eventsource --save
> global.EventSource = require('eventsource');
> ```
---
## Usage
Expand All @@ -77,7 +79,6 @@ const result = await client.records.getList('example', 1, 20, {
// authenticate as regular user
const userData = await client.users.authViaEmail('[email protected]', '123456');
// or as admin
const adminData = await client.admins.authViaEmail('[email protected]', '123456');
Expand All @@ -88,7 +89,7 @@ const adminData = await client.admins.authViaEmail('[email protected]', '123456')

## Caveats

#### File upload
### File upload

PocketBase Web API supports file upload via `multipart/form-data` requests,
which means that to upload a file it is enough to provide a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object as body.
Expand Down Expand Up @@ -125,7 +126,7 @@ formData.append('title', 'Hello world!');
const createdRecord = await client.Records.create('example', formData);
```

#### Errors handling
### Errors handling

All services return a standard [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)-based response, so the error handling is straightforward:
```js
Expand Down Expand Up @@ -157,26 +158,32 @@ ClientResponseError {
}
```

#### AuthStore
### AuthStore

The SDK keeps track of the authenticated token and auth model for you via the `client.authStore` instance.
It has the following public members that you can use:

The default [`LocalAuthStore`](https://github.com/pocketbase/js-sdk/blob/master/src/stores/LocalAuthStore.ts) uses the browser's `LocalStorage` if available, otherwise - will fallback to runtime/memory (aka. on page refresh or service restart you'll have to authenticate again).

The default `client.authStore` extends [`BaseAuthStore`](https://github.com/pocketbase/js-sdk/blob/master/src/stores/BaseAuthStore.ts) and has the following public members that you can use:

```js
AuthStore {
// fields
token: string // the authenticated token
model: User|Admin|{} // the authenticated User or Admin model
isValid: boolean // checks if the store has existing and unexpired token
BaseAuthStore {
// base fields
token: string // the authenticated token
model: User|Admin|null // the authenticated User or Admin model
isValid: boolean // checks if the store has existing and unexpired token

// methods
// main methods
clear() // "logout" the authenticated User or Admin
save(token, model) // update the store with the new auth data
onChange(callback) // register a callback that will be called on store change

// cookie parse and serialize helpers
loadFromCookie(cookieHeader, key = 'pb_auth')
exportToCookie(options = {}, key = 'pb_auth')
}
```

By default the SDK initialize a [`LocalAuthStore`](https://github.com/pocketbase/js-sdk/blob/master/src/stores/LocalAuthStore.ts), which uses the browser's `LocalStorage` if available, otherwise - will fallback to runtime/memory store (aka. on page refresh or service restart you'll have to authenticate again).

To _"logout"_ an authenticated user or admin, you can just call `client.authStore.clear()`.

To _"listen"_ for changes in the auth store, you can register a new listener via `client.authStore.onChange`, eg:
Expand All @@ -194,13 +201,16 @@ If you want to create your own `AuthStore`, you can extend [`BaseAuthStore`](htt
import PocketBase, { BaseAuthStore } from 'pocketbase';

class CustomAuthStore extends BaseAuthStore {
...
save(token, model) {
super.save(token, model);
// your custom business logic...
}
}

const client = new PocketBase('http://127.0.0.1:8090', 'en-US', CustomAuthStore());
```

#### Auto cancellation
### Auto cancellation

The SDK client will auto cancel duplicated pending requests for you.
For example, if you have the following 3 duplicated calls, only the last one will be executed, while the first 2 will be cancelled with `ClientResponseError` error:
Expand Down Expand Up @@ -232,7 +242,7 @@ To manually cancel pending requests, you could use `client.cancelAllRequests()`
> If you want to completelly disable the auto cancellation behavior, you could use the `client.beforeSend` hook and
delete the `reqConfig.signal` property.

#### Send hooks
### Send hooks

Sometimes you may want to modify the request sent data or to customize the response.

Expand Down Expand Up @@ -268,9 +278,199 @@ To accomplish this, the SDK provides 2 function hooks:
};
```

### SSR integration

Unfortunately, **there is no "one size fits all" solution** because each framework handle SSR differently (_and even in a single framework there is more than one way of doing things_).

But in general, the idea is to use a cookie based flow:

1. Create a new `PocketBase` instance for each server-side request
2. "Load/Feed" your `client.authStore` with data from the request cookie
3. Perform your application server-side actions
4. Before returning the response to the client, update the cookie with the latest `client.authStore` state

All [`BaseAuthStore`](https://github.com/pocketbase/js-sdk/blob/master/src/stores/BaseAuthStore.ts) instances have 2 helper methods that
should make working with cookies a little bit easier:

```js
// update the store with the parsed data from the cookie string
client.authStore.loadFromCookie('pb_auth=...');
// exports the store data as cookie, with option to extend the default SameSite, Secure, HttpOnly, Path and Expires attributes
client.authStore.exportToCookie({ httpOnly: false }); // Output: 'pb_auth=...'
```

Below you could find several examples:

<details>
<summary><strong>SvelteKit</strong></summary>

One way to integrate with SvelteKit SSR could be to create the PocketBase client in a [hook handle](https://kit.svelte.dev/docs/hooks#handle)
and pass it to the other server-side actions using the `event.locals`.

```js
// src/hooks.js
import PocketBase from 'pocketbase';
export async function handle({ event, resolve }) {
event.locals.pocketbase = new PocketBase("http://127.0.0.1:8090");
// load the store data from the request cookie string
event.locals.pocketbase.authStore.loadFromCookie(event.request.headers.get('cookie') || '');
const response = await resolve(event);
// send back the default 'pb_auth' cookie to the client with the latest store state
response.headers.set('set-cookie', event.locals.pocketbase.authStore.exportToCookie());
return response;
}
```

And then, in some of your server-side actions, you could directly access the previously created `event.locals.pocketbase` instance:

```js
// src/routes/login/+server.js
//
// creates a `POST /login` server-side endpoint
export function POST({ request, locals }) {
const { email, password } = await request.json();
const { token, user } = await locals.pocketbase.users.authViaEmail(email, password);
return new Response('Success...');
}
```
</details>

<details>
<summary><strong>Nuxt 3</strong></summary>

One way to integrate with Nuxt 3 SSR could be to create the PocketBase client in a [nuxt plugin](https://v3.nuxtjs.org/guide/directory-structure/plugins)
and provide it as a helper to the `nuxtApp` instance:

```js
// plugins/pocketbase.js
import PocketBase from 'pocketbase';
export default defineNuxtPlugin((nuxtApp) => {
return {
provide: {
pocketbase: () => {
const client = new PocketBase('http://127.0.0.1:8090');
// load the store data from the request cookie string
client.authStore.loadFromCookie(nuxtApp.ssrContext?.event?.req?.headers?.cookie || '');
// send back the default 'pb_auth' cookie to the client with the latest store state
client.authStore.onChange(() => {
if (nuxtApp.ssrContext?.event?.res) {
nuxtApp.ssrContext.event.res.setHeader('set-cookie', client.authStore.exportToCookie());
}
});
return client;
}
}
}
});
```

And then in your component you could access it like this:

```html
<template>
<div>
Show: {{ data }}
</div>
</template>
<script setup>
const { data } = await useAsyncData(async (nuxtApp) => {
const client = nuxtApp.$pocketbase();
// fetch and return all "demo" records...
return await client.records.getFullList('demo');
})
</script>
```

> For Nuxt 2 you could use similar approach, but instead of `nuxtApp` you could use a store state to store/create the local `PocketBase` instance.
</details>

<details>
<summary><strong>Next.js</strong></summary>

Next.js doesn't seem to have a central place where you can read/modify the server request and response.
[There is support for middlewares](https://nextjs.org/docs/advanced-features/middleware),
but they are very limited and, at the time of writing, you can't pass data from a middleware to the `getServerSideProps` functions (https://github.com/vercel/next.js/discussions/31792).

One way to integrate with Next.js SSR could be to create a custom `PocketBase` instance in each of your `getServerSideProps`:

```jsx
import PocketBase, { BaseAuthStore } from 'pocketbase';
class NextAuthStore extends BaseAuthStore {
constructor(req, res) {
super();
this.req = req;
this.res = res;
this.loadFromCookie(this.req?.headers?.cookie);
}
save(token, model) {
super.save(token, model);
this.res?.setHeader('set-cookie', this.exportToCookie());
}
clear() {
super.clear();
this.res?.setHeader('set-cookie', this.exportToCookie());
}
}
export async function getServerSideProps({ req, res }) {
const client = new PocketBase("https://pocketbase.io");
client.authStore = new NextAuthStore(req, res);
// fetch example records...
const result = await client.records.getList("example", 1, 30);
return {
props: {
// ...
},
}
}
export default function Home() {
return (
<div>Hello world!</div>
)
}
```
</details>

### Security

The most common frontend related vulnerability is XSS (and CSRF when dealing with cookies).
Fortunately, modern browsers can detect and mitigate most of this type of attacks if [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) is provided.

**To prevent a malicious user or 3rd party script to steal your PocketBase auth token, it is recommended to configure a basic CSP for your application (either as `meta` tag or HTTP header).**

This is out of the scope of the SDK, but you could find more resources about CSP at:

- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
- https://content-security-policy.com


## Definitions

#### Creating new client instance
### Creating new client instance

```js
const client = new PocketBase(
Expand All @@ -280,7 +480,7 @@ const client = new PocketBase(
);
```

#### Instance methods
### Instance methods

> Each instance method returns the `PocketBase` instance allowing chaining.

Expand All @@ -292,7 +492,7 @@ const client = new PocketBase(
| `client.buildUrl(path, reqConfig = {})` | Builds a full client url by safely concatenating the provided path. |


#### API services
### API services

> Each service call returns a `Promise` object with the API response.

Expand Down
Loading

0 comments on commit 8a7dd2a

Please sign in to comment.