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

SSR support #15

Closed
fvcoder opened this issue Jul 20, 2022 · 27 comments
Closed

SSR support #15

fvcoder opened this issue Jul 20, 2022 · 27 comments

Comments

@fvcoder
Copy link

fvcoder commented Jul 20, 2022

I am currently exploring Pocketbase using their Pocketbase Js with Remix Js.
The problem is that I can't adapt the operation of AuthStore with each request to the server.

Suggestion: To solve the drawback can an additional parameter to set the token manually.

pocketbaseClient.Users.getOne('idUser', {}, 'User eyJh...')

[Original]
Compatibilidad con SSR

Actualmente estoy explorando Pocketbase usando su Pocketbase Js con Remix Js.
El problema es que no puedo adaptar el funcionamiento de AuthStore con cada petición al servidor.

Sugerencia: Para solucionar el inconveniente pueden un parámetro adicional para poner el token manualmente

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Jul 20, 2022

@thefersh Could you elaborate more a little on your use case and why the default AuthStore doesn't work for you?

You can create your own AuthStore and attach it during the client initialization. The only requirements is to be compatible with the AuthStore type interface. For example:

class MyCustomAuthStore {
    token = "";
    model = {};
    isValid = false;

    save(token, model) {
        // implement save logic...
    }

    clear() {
        // implement clear logic...
    }
}

const client = new PocketBase("http://localhost:8090", 'en-US', new MyCustomAuthStore());

@fvcoder
Copy link
Author

fvcoder commented Jul 21, 2022

@ganigeorgiev you are right.
can you make an example of implementation with Next Js or Remix Js?
To be clear how I can implement AuthStore

@ganigeorgiev
Copy link
Member

The above example should be framework agnostic. There shouldn't be a need for a special nextjs/remix implementation.

Could you elaborate why the default implementation doesn't work in your case, so that I can understand better what actually prevents you to use it in nextjs/remix?

@oswaldohuillca
Copy link

Hola, He logrado implementar autentificación para SSR, esto debería ser agnóstico de librerías o frameworks como nextjs/remix. en mi caso he utilizado sveltekit.

import PocketBase, { User, Admin } from 'pocketbase'
import { setCookie, parseCookies, destroyCookie } from 'nookies'
import jwt_decode from 'jwt-decode'

class CustomAuthStore {
  private storageKey: string

  constructor(storageKey = 'pocketbase') {
    this.storageKey = storageKey
  }

  get isValid(): boolean {
    if (!this.token) return false
    const { exp } = jwt_decode<{ exp: number, id: string, type: string }>(this.token)
    if (exp > Date.now() / 100) return false
    return true
  }

  get token(): string {
    const { token } = this.getCookie()
    if (!token) return ''
    return token
  }

  get model(): User | Admin | {} {
    const { model } = this.getCookie()
    if (!model) return {}

    // admins don't have `verified` prop
    if (typeof model?.verified !== 'undefined') {
      return new User(model)
    }

    return new Admin(model)
  }

  private getCookie() {
    const cookies = parseCookies()
    const authorization = cookies[this.storageKey]
    if (!authorization) return {}
    return JSON.parse(authorization)
  }

  save(token: string, model: {}) {
    setCookie(null, this.storageKey, JSON.stringify({ token, model }), {
      maxAge: 30 * 24 * 60 * 60,
      path: '/',
    })
  }

  clear() {
    destroyCookie(null, this.storageKey)
  }
}

export const pocketbase = new PocketBase('http://localhost:8090', 'en-US', new CustomAuthStore())

@ganigeorgiev
Copy link
Member

If you are going with storing the auth token in a cookie, then I would recommend setting Secure, HttpOnly and SameSite=Strict attributes (for more info - https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security).

@ollema
Copy link
Contributor

ollema commented Aug 14, 2022

@oswaldohuillca in your AuthStore you are not actually setting server side cookies right?

based on https://github.com/maticzav/nookies#reference, calling parseCookies, setCookie or destroyCookie with ctx to null or undefined will set client side cookies.

this means that it's not possible to set the HttpOnly attribute as suggested by @ganigeorgiev, this is even reflected in the source code:

https://github.com/maticzav/nookies/blob/master/packages/nookies/src/index.ts#L111

  if (isBrowser()) {
    if (options && options.httpOnly) {
      throw new Error('Can not set a httpOnly cookie in the browser.')
    }

    document.cookie = cookie.serialize(name, value, options)

so I guess we are back to square one.

I struggle to understand how one should get/set server side cookies within the AuthStore interface. to do that, one would need to get/set the headers of response object, which is not passed as a parameter to save() 🤔

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Aug 14, 2022

@ollema There are several ways to pass the the ctx/response object but the issue is that this object could have completely different API depending on the node framework you are using and I'm not sure if there is a general solution to this (I haven't had a chance to research it yet).

The response object could be provided during initialization as constructor argument to your custom store, as a setter/provider function (eg. withContext(ctx)), etc.:

import PocketBase, { BaseAuthStore } from 'pocketbase';

class CustomAuthStore extends BaseAuthStore {
    constructor(resp) {
        super();

        this.resp = resp;
    }

    ...

    save(token, model) {
        super.save(token, model);

        this.resp.setHeader(...)
    }

    clear() {
        super.clear();

        this.resp.setHeader(...)
    }
}

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

@ollema
Copy link
Contributor

ollema commented Aug 14, 2022

The response object could be provided during initialization as constructor argument to your custom store, as a setter/provider function (eg. withContext(ctx)), etc.:

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

hmm, is the idea that you would create a new CustomAuthStore and new client for every request/response?

rather than keeping a "singleton" client export from a module? (I'm not sure if I'm using the correct terms here)

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Aug 14, 2022

Yes, creating a new client instance for each request/response and using the cookie AuthStore to load the auth data is the easiest way to solve this, but as mentioned there are other approaches to load the response object depending on your use case.

Using a single global client/authStore instance may not always work since it could conflict with how node processes requests in the event loop (eg. startRequest1 -> wait for some db1/network1 call -> in the meantime startRequest2 -> wait for some db2/network2 call -> return db1 response -> return db2 response, etc.).

I'll try to spend some time on this researching the possible approaches, but that will be after v0.5.0 release of the main repo.

@ollema
Copy link
Contributor

ollema commented Aug 21, 2022

@ganigeorgiev figured I would give an update on the AuthStore situation with SvelteKit SSR.

After doing some research it seems like this is the most "idiomatic" approach (for now, SvelteKit is still not 1.0 so things could change!):


Like you suggested you would use a new client instance for each request/response.

In SvelteKit, this could be handled in the handle function in hooks.ts.

src/hooks.ts

import type { Handle } from '@sveltejs/kit';
import PocketBase, { User } from 'pocketbase';
import cookie from 'cookie';

export const handle: Handle = async ({ event, resolve }) => {
	const client = new PocketBase('http://127.0.0.1:8090');
	event.locals.pocketbase = client;

	const { token, user } = cookie.parse(event.request.headers.get('Cookie') ?? '');

	if (!token || !user) {
		return await resolve(event);
	}

	client.authStore.save(token, new User(JSON.parse(user)));
	if (client.authStore.isValid) {
		event.locals.user = client.authStore.model as User;
	}

	return await resolve(event);
};

By setting event.locals.pocketbase to client, the client will be available in handlers in +server.ts and server-only load functions (handle docs)

In the handle function above, I have also populated event.locals.user with the current user if the token is valid.


Now, while the handle function above can be used to access the client and/or the current user in SSR, how do we authenticate a user or in other words store the token and (user)model?

If I understood it correctly, you would create server endpoints for this, for example:
src/routes/auth/signin/+server.ts

import type { RequestHandler } from '@sveltejs/kit';
import cookie from 'cookie';

const defaultCookieOptions = { maxAge: 30 * 24 * 60 * 60, path: '/', httpOnly: true, sameSite: true, secure: true };

export const POST: RequestHandler = async ({ request, locals }) => {
	const response = new Response('{}');

	const { email, password } = await request.json();
	try {
		const { token, user } = await locals.pocketbase.users.authViaEmail(email, password);
		locals.pocketbase.authStore.save(token, user);
		locals.user = user;
		response.headers.append('Set-Cookie', cookie.serialize('token', token, defaultCookieOptions));
		response.headers.append('Set-Cookie', cookie.serialize('user', JSON.stringify(user), defaultCookieOptions));
	} catch {}

	return response;
};

and
src/routes/auth/signout/+server.ts

import type { RequestHandler } from '@sveltejs/kit';
import cookie from 'cookie';

const defaultCookieOptions = { maxAge: -1, path: '/', httpOnly: true, sameSite: true, secure: true };

export const POST: RequestHandler = async ({ locals }) => {
	const response = new Response('{}');

	locals.pocketbase.authStore.clear();
	locals.user = undefined;
	response.headers.append('Set-Cookie', cookie.serialize('token', '', defaultCookieOptions));
	response.headers.append('Set-Cookie', cookie.serialize('user', '', defaultCookieOptions));

	return response;
};

It should be noted that the snippets above do not have any error handling, they can just be seen as inspiration.

To update the profile of the currently logged in user, you could add an additional /auth/save route for example.


Final thoughts

  • For PocketBase + SvelteKit SSR, the default AuthStore can be used today with the help of hooks.ts and some server endpoints
  • With that said, any official SSR AuthStore that could simplify the method above would be very nice!
  • If no official AuthStore for SSR will be added - then maybe (a revised) version of this method (and similar versions for other frameworks) could be added to the docs?
  • I am not experienced with either JS, Svelte, SvelteKit or PocketBase, so take this with a grain of salt 😅

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Aug 22, 2022

@ollema I like your approach with the locals and how you share the client instance from the hook context.

I would probably only move all cookie handling logic inside the hook handler so that all other server actions could operate only with the locals.client.authStore.save/clear methods. Or in other words, your hook could have the following structure:

export const handle = async ({ event, resolve }) => {
  const client = new PocketBase('http://127.0.0.1:8090');
  event.locals.pocketbase = client;

  // load auth data into the client from a request cookie
  // ...

  const response = await resolve(event);

  // update the response cookie header(s) with the latest `client.authStore` state
  // in case it was modified by some of the actions
  // ...

  return response;
};

For this week, I have planned after work to explore the SSR handling in the other frameworks (nextjs, nuxt2, nuxt3, remix), but I have doubts that there is a single solution that will work out of the box for all of them (even only for SvelteKit, depending which server handler you use and how you structure your app, there are different exported objects to access and update the request/response headers).

Supabase seems to deal with this by providing various auth helpers but I want to avoid that because it will be hard to maintain by myself in the long run.

I still have to do a more throughout research, but in general I think we can improve at least a little the SSR handling in 2 ways:

  • add 2 new cookie helper methods to the default auth store:

    • fromCookie(cookie: string, name = 'pb_auth') - load auth data from the serialized cookie string
    • toCookie(options, [existingCookieStrToAppendTo]): string - export as serialized cookie header string

    This way it will be up to the developers to decide how to load and set the cookie. We only will handle parsing and serialization.

  • create a SSR help document with some short examples and guides similar to yours for other frameworks (nextjs, nuxt2/3, remix)

As a side note, in order to support mixed browser and server-side usage at the same time, we also probably would have to set by default the HttpOnly to false (at the end it isn't too much different than using the LocalStorage).
My main cookie security related concern is with CSRF attacks, but that is handled by the SameSite=Strict attribute.
For XSS prevention in general I'll add a note for the developers to consider defining a basic Content-Security-Policy (either set as meta tag or as HTTP header).

@ganigeorgiev
Copy link
Member

I've explored the available options the last couple of days, but I couldn't find a "one size fit all" solution, so I've implemented the following in the latest v0.6.0 SDK release:

  • I've added 2 cookie helper methods to the BaseAuthStore to simplify working with cookies:

    // 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=...'

    The exported cookie uses the token expiration date and also truncate the user model to its minimum (id, email, [verified]) in case it exceed 4096 bytes (this should be a very rare case).

  • I've added some examples for SvelteKit (based on @ollema suggestion), Nuxt 3 and Next.js in https://github.com/pocketbase/js-sdk#ssr-integration.

The above should help (or at least to give you some idea) how to deal with SSR, but if someone still have difficulties making it work, feel free to let me know and I'll try to provide some guidance based on your use case.

@aphilas
Copy link

aphilas commented Aug 26, 2022

The two helper methods have greatly simplified auth for me (I am using SvelteKit).

Assuming the naming of the cookie is transparent to the user, isn't there a need for an orthogonal helper method to clear the auth cookie?

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Aug 26, 2022

@aphilas As mentioned in the above discussion, the 2 helper methods only parse and serialize cookie strings and doesn't handle the actual cookie fetch/set because each framework uses different interface and methods for working with the server request and response objects (eg. it could be response.setHeader(), or response.headers.set(), or whatever else the framework is using; there are differences even within a single framework depending for example whether the underlying node server is express, h3 or else).


isn't there a need for an orthogonal helper method to clear the auth cookie?

No because if you are following the example from https://github.com/pocketbase/js-sdk#ssr-integration, the cookie will be set as "expired" on client.authStore.clear() so you shouldn't have to bother manually deleting it. The end goal is to use only client.authStore and leave the hook handle to take care for the cookie behind the scene.

@aphilas
Copy link

aphilas commented Aug 26, 2022

Oh thanks, that makes sense. I was manually clearing the cookie.

@ganigeorgiev
Copy link
Member

ganigeorgiev commented Aug 26, 2022

@aphilas Also note that because by default client.authStore.exportToCookie() will generate a cookie string with the HttpOnly attribute, the cookie can be accessed and set only server-side (which is recommended), so you have to create a "logout" SvelteKit server action that calls client.authStore.clear(), eg:

// src/routes/logout/+server.js
//
// creates a `POST /logout` server-side endpoint
export function POST({ request, locals }) {
    locals.pocketbase.authStore.clear();

    // return a message or you may want to redirect to some other page
    return new Response('Success logout...');
}

If you want to be able to clear the cookie from both server-side and client-side, you'll have to:

  1. set explicitly HttpOnly to false, eg. client.authStore.exportToCookie({ httpOnly: false })
  2. add a onChange listener to the browser client instance to set the cookie using document.cookie (or the experimental CookieStore which us currently supported only by Chrome/Edge), eg. something like:
client.authStore.onChange(() => {
    document.cookie = client.authStore.exportToCookie({ httpOnly: false });
});

@aphilas
Copy link

aphilas commented Aug 26, 2022 via email

@ChristiamUrvites
Copy link

I've explored the available options the last couple of days, but I couldn't find a "one size fit all" solution, so I've implemented the following in the latest v0.6.0 SDK release:

* I've added 2 cookie helper methods to the `BaseAuthStore` to simplify working with cookies:
  ```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=...'
  ```
  
  
      
        
      
  
        
      
  
      
    
  > The exported cookie uses the token expiration date and also truncate the user model to its minimum (id, email, [verified]) in case it exceed 4096 bytes (this should be a very rare case).

* I've added some examples for SvelteKit (based on @ollema suggestion), Nuxt 3 and Next.js in https://github.com/pocketbase/js-sdk#ssr-integration.

The above should help (or at least to give you some idea) how to deal with SSR, but if someone still have difficulties making it work, feel free to let me know and I'll try to provide some guidance based on your use case.

Hi I'm trying to follow the example for sveltekit but crashes on the hooks file the moment it calls the exportToCookie function, it throws:

Blob is not defined

ReferenceError: Blob is not defined
    at n.t.exportToCookie (file:///C:/Users/Uribe/devs/svelte/gekkos-app/node_modules/pocketbase/dist/pocketbase.es.mjs:1:8183)
    at Object.handle (/C:\Users\Uribe\devs\svelte\gekkos-app\src\hooks:18:74)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async respond (file:///C:/Users/Uribe/devs/svelte/gekkos-app/node_modules/@sveltejs/kit/src/runtime/server/index.js:215:20)
    at async file:///C:/Users/Uribe/devs/svelte/gekkos-app/node_modules/@sveltejs/kit/src/vite/dev/index.js:383:22

my hook file:

import PocketBase from 'pocketbase';

export async function handle({ event, resolve }) {
	event.locals.pocketbase = new PocketBase("http://127.0.0.1:8090", "es-ES");

    // 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({ httpOnly: false }));

    return response;
}

I'm still learning so what could I be missing? 🤔

Thx in advance!

@ganigeorgiev
Copy link
Member

@IHummer Could you please check what version of node.js are you using? Blob should be available in node 15.7+ (I'll consider making the Blob check option in the next release).

@ganigeorgiev
Copy link
Member

@IHummer I've just checked. Blob is available in the global namespace in node 18, but in earlier version it is required to be imported from the buffer package...

I'll publish shortly a v0.6.2 release making it optional since it is used only for a very edge case (when the cookie exceed 4096 and there are multi-byte characters like emojis in the serialized json).

@ChristiamUrvites
Copy link

@IHummer I've just checked. Blob is available in the global namespace in node 18, but in earlier version it is required to be imported from the buffer package...

I'll publish shortly a v0.6.2 release making it optional since it is used only for a very edge case (when the cookie exceed 4096 and there are multi-byte characters like emojis in the serialized json).

I was using node v16.16 xd

Thanks for the support!

@ganigeorgiev
Copy link
Member

@IHummer I've just released a v0.6.2 of the SDK with a fallback check when Blob is not available.

Please update your dependencies (npm update) and try again.

@ChristiamUrvites
Copy link

@IHummer I've just released a v0.6.2 of the SDK with a fallback check when Blob is not available.

Please update your dependencies (npm update) and try again.

I've just checked and it's working without problems

thanks!!

@TannerGabriel
Copy link

@ganigeorgiev It would probably be good to add a Nuxt2 example since it is the current stable version and has many more users than Nuxt3.

@Xa-vie
Copy link

Xa-vie commented Jan 7, 2023

Confused about next js 13 implementation. Please Help

@subhasish-smiles
Copy link

Started playing with solid-start and pocketbase. Couldn't make pocketbase work inside routeData function.
pb.authStore.model

@ganigeorgiev
Copy link
Member

@subhasish-smiles Please open a new issue with more details about your setup and steps to reproduce (pseudo-code/code sample would be also helpful).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Development

No branches or pull requests

9 participants