pocketbase / js-sdk

PocketBase JavaScript SDK
https://www.npmjs.com/package/pocketbase
MIT License
2.04k stars 120 forks source link

Can't set up properly Next.js's SSR #69

Closed ticianomorvan closed 8 months ago

ticianomorvan commented 1 year ago

I've followed the examples displayed in the README, as well as looked for solutions in other frameworks but I simply can't fix this problem.

Doing a little bit of debug and research, I can come to the conclusion that the cookie contained by the request may be not what the authStore expects, because, according to the examples, the key pb_auth= is set as the default, while my cookie has the following structure:

a_session_[code]_legacy=[token]

Also, if it has anything to do with this, the token stored in localStorage it's not equal to the one served on the cookie.

As extra information, I leave the code I wrote.

type ServerRequest = IncomingMessage & {
  cookies: Partial<{
    [key: string]: string
  }>
}

class CustomAuthStore extends BaseAuthStore {
  public request: ServerRequest
  public response: ServerResponse<IncomingMessage>

  constructor (request: ServerRequest, response: ServerResponse<IncomingMessage>) {
    super()

    this.request = request
    this.response = response

    this.loadFromCookie(
      this.request.headers.cookie ?? ''
    )
  }

  save (token: string, model: User | Admin | null): void {
    super.save(token, model)

    this.response.setHeader('set-cookie', this.exportToCookie())
  }

  clear (): void {
    super.clear()

    this.response.setHeader('set-cookie', this.exportToCookie())
  }
}

Versions: pocketbase v0.7.10; pocketbase (sdk) v0.7.4

ganigeorgiev commented 1 year ago

This seems like a common misunderstanding but I'm not sure how to explain it better.

First you need to clarify how do you plan to interact with the SDK:


Also, if it has anything to do with this, the token stored in localStorage it's not equal to the one served on the cookie.

I don't understand this. When the SDK is instantieted for example in getServerSideProps you don't have access anymore to the LocalStorage. Do you mean that you use the SDK both in the client and server? If that's the case then you'll have to change the CustomAuthStore save and clear methods to get and set the cookie with some condition either from ServerRequest/ServerResponse or from document.cookie. In addition, when the mixed approach is used you have to export the cookie with httpOnly: false in order to allow the js running in the browser to have access to it, aka: this.exportToCookie({ httpOnly: false })


Doing a little bit of debug and research, I can come to the conclusion that the cookie contained by the request may be not what the authStore expects, because, according to the examples, the key pb_auth= is set as the default, while my cookie has the following structure: a_session_[code]_legacy=[token]

I'm not sure what this cookie is for. Is this some nextjs cookie or some other custom cookie?


In any case, you need to specify where your code is running (server, client or both). The CustomAuthStore from above will work only in a server-side handler context. If you are not sure, you can add a console.log("foo") when initializing the SDK store and see where the text is printed - in the browser console (aka. client) and/or in command line terminal (server).

ticianomorvan commented 1 year ago

First of all, I apologize if I wasn't clear enough. I was trying to run Pocketbase queries server-side for better performance, but it didn't work. The a_session... cookie is the only one that appears to be generated from the OAuth2 flow, as well as it's created a localStorage entry with the user's model and token. But, as you said, localStorage doesn't have anything to do when we're on the server side.

The thing is that I can't understand how does the cookie that loadFromCookie needs has to look like.

ganigeorgiev commented 1 year ago

The thing is that I can't understand how does the cookie that loadFromCookie needs has to look like.

The default cookie entry should look something like this:

pb_auth=...encoded token and model json data...; Path=/; Expires=Thu, 27 Jun 2030 10:00:00 GMT; HttpOnly; Secure; SameSite=Strict

(the expires date should be the the same as the token "exp" claim)


The a_session... cookie is the only one that appears to be generated from the OAuth2 flow, as well as it's created a localStorage entry with the user's model and token.

If you have a localStorage entry, this means that you are currently handling the OAuth2 redirect in the browser. Or in other words, the PocketBase instance is running client-side. If you want to have a mixed SDK access (aka. making requests both client-side and server-side) then you'll have to export the cookie with exportToCookie({ httpOnly: false }) and change your store to conditionally get and set the cookie either from the ServerRequest/ServerResponse objects or from document.cookie depending on what environment it is running. Alternatively the browser/client SDK instance could use the authStore.onChange listener instead of a custom store:

import PocketBase from 'pocketbase';

const client = new PocketBase("http://127.0.0.1:8090");

client.authStore.loadFromCookie(document.cookie);

client.authStore.onChange(() => {
    document.cookie = client.authStore.exportToCookie({ httpOnly: false });
})

export default client;

(the above will always update the document.cookie that will be added automatically to every request from the browser to the node-server)


Could you provide a code sample/repo of what you are trying to do?

ticianomorvan commented 1 year ago

Well, I added the code you wrote and simply worked as I was expecting, being able to work client-side as well as server-side.

My final code looks something like this:

// lib/server.ts
import Pocketbase from 'pocketbase'

const client = new Pocketbase(process.env.NEXT_PUBLIC_POCKETBASE)

typeof document !== 'undefined' && client.authStore.loadFromCookie(document.cookie)

client.authStore.onChange(() => {
  document.cookie = client.authStore.exportToCookie({ httpOnly: false })
})

export default client

A final question on this because I think it's solved, it's better to have an unique PocketBase instance or to instantiate it at every request?

ganigeorgiev commented 1 year ago

I think there is some misunderstanding. The above will work only in a browser context because document.cookie is not available in node. If you are making the requests in the browser while only using node for server rendering then that's OK.

But if you want to make requests from the node-server you'll need to read and set the cookie from the ServerRequest and ServerResponse objects (or their equivalent in the new nextjs if you are using nextjs13). When using PocketBase in a server context, you need a unique PocketBase instance on each server request because node is single threaded and requests are usually executed in an event loop, meaning that while you are waiting something to execute, the same process could process another client request and if you use only a single instance the data from the new request may overwrite the state from the initial one. In the browser this is not an issue and you can have a single instance for the entire lifecycle of the application.

I understand that modern frameworks blur the line between client and server but please make sure that your code is executed where you expect it to avoid accidentally leaking sensitive information. I still haven't got the time to explore the new api of nextjs13 and sometime after the v0.8.0 release I'll try to test it and will add a SSR example for it in the readme.

lifeitech commented 1 year ago

I need your NextJS 13 SSR example so much! I'm using NextJS 13 with PocketBase. I'm very confused with how to use PocketBase for user authentication in client components and then fetching user's data in server components.

In my project, I didn't get and set cookies (I don't know how to do that), and I'm not able to get user's data after user login. It is as if the PocketBase client in the server component that performs data fetching is never aware of the PocketBase client in the client component that performs user authentication.

lifeitech commented 1 year ago

Note: according to https://beta.nextjs.org/docs/api-reference/cookies, "the cookies function is read-only. Outgoing requests cookies cannot be set or delete". It also says that "The Next.js team at Vercel is working on adding the ability to set cookies in addition to the cookies function.".

I'm not sure if this implies that currently we are not able to set cookies when making requests from the server.

ticianomorvan commented 1 year ago

I need your NextJS 13 SSR example so much! I'm using NextJS 13 with PocketBase. I'm very confused with how to use PocketBase for user authentication in client components and then fetching user's data in server components.

In my project, I didn't get and set cookies (I don't know how to do that), and I'm not able to get user's data after user login. It is as if the PocketBase client in the server component that performs data fetching is never aware of the PocketBase client in the client component that performs user authentication.

In my case, I was working with Next.js 12 (mostly it was something I knew better) so I can't give you a precise example. What I can say to you is that, while I was working on it, Next didn't really append any cookie to the requests, I read someone said that it was because of the localhost but I didn't investigate further on that. After Gani's reply, I was able to work with the cookie as it was being exported to the server side when it changed. So maybe you can try to append that onChange method to your Pocketbase's instance and then using the exported model for your queries, but I really don't know how Pocketbase works in Next.js 13

lifeitech commented 1 year ago

In my case, I was working with Next.js 12 (mostly it was something I knew better) so I can't give you a precise example. What I can say to you is that, while I was working on it, Next didn't really append any cookie to the requests, I read someone said that it was because of the localhost but I didn't investigate further on that. After Gani's reply, I was able to work with the cookie as it was being exported to the server side when it changed. So maybe you can try to append that onChange method to your Pocketbase's instance and then using the exported model for your queries, but I really don't know how Pocketbase works in Next.js 13

Thank you for your reply! I tried a little bit and it seems to solve my current problem, which is to get data on the server side after user login. Following Gani's suggestion, I create a new PocketBase client instance, get the cookie using the cookies().get('pb_auth') method in Next.js 13, which returns an object {name: 'pb_auth', value: '{...}'}, and then convert it to a string, then load the cookie string with authStore.loadFromCookie. Now I'm able to read data using client.records.getList. A minimal example:

// app/something/page.tsx. This is a server component. 
import PocketBase from 'pocketbase';
import { cookies } from 'next/headers';

export default async function Home() {
  const client = new PocketBase('http://127.0.0.1:8090');
  const nextCookies = cookies();
  const cookie_object = nextCookies.get('pb_auth');
  const cookie_string = cookie_object.name + '=' + cookie_object.value;
  client.authStore.loadFromCookie(cookie_string);
  const data = await client.records.getList('collection_name', 1, 100);

  return (
    <div>
    <h1>Results</h1>
    <div>
      {data.items.map(item => {
        return <SomeComponent key={item.id} item={item}/>;
      })}
    </div>
    </div>
    )
}

I'm quite new to web development and not very familiar with JS/TS, so maybe there are better ways to write the code.

ticianomorvan commented 1 year ago

Hey! I didn't know about cookies.get() method, seems to be the way to follow in this case. Don't apologize for being new to webdev, everyone starts from somewhere! Also, If you get to a stable solution, try to share it with other people! It's all about cooperative work. Good work :)

samducker commented 1 year ago

So what I did for my solution is the following...

  1. Run get serverSideProps whenever I want to detect the logged in state, protect a route, or get some user data
  2. If no cookie is present redirect to /login page
  3. If cookie is present run client.collection("users").authRefresh to get the user object, then just return the attributes I want to the client props
import Cookies from "cookies";
import { GetServerSideProps } from "next";
import client from "@services/config";

interface Data {
  firstName: string;
  lastName: string;
  id: string;
  avatar: string;
  isLoggedIn: boolean;
}

export const getServerSideProps: GetServerSideProps<{
  userData: Data;
}> = async (context) => {
  const { req, res } = context;
  const cookies = new Cookies(req, res);
  const pbCookie = cookies.get("pb_auth");

  if (!pbCookie) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }
  client.authStore.loadFromCookie(`pb_auth=${pbCookie}`);
  const data = await getUserProfile();

  const userData = {
    firstName: data.record.firstName,
    lastName: data.record.lastName,
    id: data.record.id,
    avatar: data.record.avatar,
    isLoggedIn: true,
  };

  return {
    props: {
      userData,
    },
  };
};

So the above helps me on the server and then for the client I have the following.

import PocketBase from "pocketbase";

export const client = new PocketBase(
  process.env.NEXT_PUBLIC_POCKETBASE_BASE_URL
);

typeof document !== "undefined" &&
  client.authStore.loadFromCookie(document.cookie);

client.authStore.onChange(() => {
  if (typeof document !== "undefined") {
    document.cookie = client.authStore.exportToCookie({ httpOnly: false });
  }
});

export default client;
rafifos commented 1 year ago

I've managed to get it working in the new app directory, using (https://github.com/vvo/iron-session/issues/560#issuecomment-1324598048 as a base. The only thing to note is that any actions that would update the authStore can only happen in the client, fow now.

lib/getUserFromCookie.ts ```ts import type { User } from "@/interfaces"; import { ReadonlyRequestCookies } from "next/dist/server/app-render"; import { pocketbase } from "@/lib"; /** * Can be called in page/layout server component. * @param cookies ReadonlyRequestCookies * @returns User or null * @author Arif "poltang" Muslax * @see {@link https://github.com/vvo/iron-session/issues/560#issuecomment-1324598048} */ function getUserFromCookie(cookies: ReadonlyRequestCookies): User | null { const authCookie = cookies.get("pb_auth"); if (!authCookie) return null; pocketbase.authStore.loadFromCookie(`${authCookie.name}=${authCookie.value}`); const user = pocketbase.authStore.model; return user as unknown as User; } export { getUserFromCookie }; ```
lib/pocketbase.ts ```ts import PocketBase from "pocketbase"; import { env } from "@/lib/env"; const pocketbase = new PocketBase(env.POCKETBASE_URL); /** @see {@link https://github.com/pocketbase/js-sdk/issues/69} */ if (typeof document !== "undefined") { pocketbase.authStore.loadFromCookie(document.cookie); pocketbase.authStore.onChange(() => { document.cookie = pocketbase.authStore.exportToCookie({ httpOnly: false }); }); } export { pocketbase }; ```

And then, in

app/layout.tsx ```ts async function RootLayout({ children }: { children: ReactNode }) { const user = await getUserFromCookie(cookies()); console.log(user); // user is available if (!user) { redirect("/auth/sign-in"); } return ( {children} ); } ```
rafifos commented 1 year ago

Also note that currently there's no way to send headers to the client inside a Server Component, it's WIP atm. (This means that refreshing the cookie everytime the store updates is not possible)

urwrstkn8mare commented 1 year ago

I'm guessing as long as we're not doing anything to the authStore on the server, just keeping the same logic and leaving the set-cookie part out will work fine. But I'm just wondering is there any issue's security wise or any-other wise?

urwrstkn8mare commented 1 year ago

Also would add the cookie to document.cookie add the cookie to every request not just requests to the nextjs server but even to thirdparty api servers?

ganigeorgiev commented 1 year ago

@samit43

I'm not sure what are your concern. If you are not using the SDK server side then the token will be stored in the browser's LocalStorage. For SSR you'll need a cookie flow if you want to "persist" the auth state between requests. It is safe as long as your cookie is set with Secure and SameSite=Strict attributes (which is the default when calling exportToCookie).

urwrstkn8mare commented 1 year ago

Oh, thank you! Sorry, I missed the SameSite=Strict and Secure attributes

mpiorowski commented 1 year ago

thx @rafifos Your solution works great. The nextjs 13 is a hard one to play with....in comparison SvelteKit is such a better expirence....

EL-MEHDI-ESSAADI commented 1 year ago

@rafifos solution is great, but what if the user data changed like the username, and on the user browser the cookie still the same with the old username, this means that the server and the browser will have incorrect user data with an old username. I think refreshing the authStore should happen on the server to get the updated user data

schmt5 commented 1 year ago

What's about using route handlers (https://nextjs.org/docs/app/building-your-application/routing/route-handlers) to get and set cookies?

My following code is not working, because onChange gets never triggered, but maybe it's an inspiration.

// app/api/pocketbase/route.ts
import { NextRequest, NextResponse } from "next/server";
import PocketBase from "pocketbase";

export async function GET(request: NextRequest) {
  const pb = new PocketBase("https://my-pock.pockethost.io");
  let authCookie = "";
  if (request.cookies.has("pb_auth")) {
    const cookie = request.cookies.get("pb_auth");
    authCookie = `${cookie?.name}=${cookie?.value}`;
  }

  pb.authStore.loadFromCookie(authCookie);
  pb.authStore.onChange(() => {
    const [key, value] = pb.authStore.exportToCookie().split("=");
    request.cookies.set(key, decodeURIComponent(value));
  });

  try {
    pb.authStore.isValid && (await pb.collection("users").authRefresh());
  } catch (_) {
    pb.authStore.clear();
  }

  return NextResponse.json({ pb_auth: pb.authStore.exportToCookie() });
}

// app/page
export default async function Home() {
  const res = await fetch('http://localhost:3000/api/pocketbase', {
    cache: 'no-cache',
  });
  const { pb_auth } = await res.json();
  const pb = new PocketBase("https://my-pock.pockethost.io");
  pb.authStore.loadFromCookie(decodeURIComponent(pb_auth));
orenaksakal commented 1 year ago

This is how I use it with app router

I have;

Remember to invalidateTag for fetchers to get refreshed data on mutation

middleware.ts ```ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import PocketBase from "pocketbase"; import { env } from "@/env.mjs"; export async function middleware(request: NextRequest) { const cookie = request.cookies.get("pb_auth")?.value; const response = NextResponse.next(); const pocketbase = new PocketBase(env.NEXT_PUBLIC_POCKET_BASE_URL); if (cookie) { const pocketbaseModel = JSON.parse(cookie); pocketbase.authStore.save(pocketbaseModel.token, pocketbaseModel.model); } try { // get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any) pocketbase.authStore.isValid && (await pocketbase.collection("users").authRefresh()); } catch (err) { // clear the auth store on failed refresh pocketbase.authStore.clear(); // clear the cookie on failed refresh response.headers.set( "set-cookie", pocketbase.authStore.exportToCookie({ httpOnly: false }) ); } if ( !pocketbase.authStore.model && !request.nextUrl.pathname.startsWith("/auth") ) { const redirectTo = new URL("/auth/signin", request.url); // add the current path as a query param to the signin URL redirectTo.search = new URLSearchParams({ next: request.nextUrl.pathname, }).toString(); return NextResponse.redirect(redirectTo); } if ( pocketbase.authStore.model && request.nextUrl.pathname.startsWith("/auth") ) { return NextResponse.redirect(new URL(request.nextUrl.origin)); } return response; } export const config = { matcher: [ /* * Match all paths except for: * 1. /api routes * 2. /_next (Next.js internals) * 3. /_static (inside /public) * 4. all root files inside /public (e.g. /favicon.ico) */ "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)", ], }; ```
actions.ts ### signin ```ts "use server"; import { cookies } from "next/headers"; import { getTokenPayload } from "pocketbase"; import { pocketbase } from "@/lib/db/pocketbase"; import { UserSignupSchemaType } from "@/lib/validations/auth"; export const signIn = async (data: any) => { try { const user = await pocketbase .collection("users") .authWithPassword(data.email, data.password); const payload = getTokenPayload(pocketbase.authStore.token); if (user) { cookies().set( "pb_auth", JSON.stringify({ token: pocketbase.authStore.token }), { path: "/", secure: false, httpOnly: true, sameSite: "strict", expires: new Date(payload.exp * 1000), } ); } return JSON.parse(JSON.stringify(user)); } catch (error) { return JSON.parse(JSON.stringify(error)); } }; ``` ### get user ```ts "use server"; import { cookies } from "next/headers"; import { getTokenPayload } from "pocketbase"; import { pocketbase } from "@/lib/db/pocketbase"; import { UserSignupSchemaType } from "@/lib/validations/auth"; export const getUser = async () => { try { const authCookie = cookies().get("pb_auth"); if (authCookie) { pocketbase.authStore.loadFromCookie( `${authCookie.name}=${authCookie.value}` ); pocketbase.authStore.isValid && (await pocketbase.collection("users").authRefresh()); } const user = pocketbase.authStore.model; return JSON.parse(JSON.stringify(user)); } catch (error) { return JSON.parse(JSON.stringify(error)); } }; ```
fetchers.ts ### get data ```ts import { unstable_cache } from "next/cache"; import { pocketbase } from "@/lib/db/pocketbase"; export async function getPosts() { return await unstable_cache( async () => await pocketbase.collection("posts").getFullList({ sort: "-created", }), [`posts`], { revalidate: 900, tags: [`posts`], } )(); } ```

Planning to release a pocketbase version of my paid nextjs starter kit Next Edge Starter soon (can remove this line if it is disturbing the author)

orenaksakal commented 1 year ago

I was able to simplify what I shared above after checking supabase's auth helpers implementation for nextjs. Basically separated server and client initializations and using cookie for checking authentication. Seems to work well for me but I never used pocketbase on production

initPocketBaseClient Should be used whenever we interact with pocketbase on client side ```ts import PocketBase from "pocketbase"; import { env } from "@/env.mjs"; export async function initPocketBaseClient() { const pocketbase: PocketBase = new PocketBase( env.NEXT_PUBLIC_POCKET_BASE_URL ); pocketbase.authStore.loadFromCookie(document.cookie); pocketbase.authStore.onChange(() => { document.cookie = pocketbase.authStore.exportToCookie({ httpOnly: false, }); }); return pocketbase; } ```
initPocketBaseServer ```ts import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import PocketBase from "pocketbase"; import { env } from "@/env.mjs"; export async function initPocketBaseServer() { const pocketbase: PocketBase = new PocketBase( env.NEXT_PUBLIC_POCKET_BASE_URL ); let response = NextResponse.next(); const authCookie = cookies().get("pb_auth"); if (authCookie) { pocketbase.authStore.loadFromCookie( `${authCookie.name}=${authCookie.value}` ); try { if (pocketbase.authStore.isValid) { await pocketbase.collection("users").authRefresh(); } } catch (error) { pocketbase.authStore.clear(); } } pocketbase.authStore.onChange(() => { response?.headers.set("set-cookie", pocketbase.authStore.exportToCookie()); }); return pocketbase; } ```
Usage ## middleware.ts ```ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { initPocketBaseServer } from "@/lib/pocketbase/server"; export async function middleware(request: NextRequest) { const pocketbase = await initPocketBaseServer(); if ( !pocketbase.authStore.model && !request.nextUrl.pathname.startsWith("/auth") ) { return NextResponse.redirect(new URL("/auth/signin", request.url)); } if ( pocketbase.authStore.model && request.nextUrl.pathname.startsWith("/auth") ) { return NextResponse.redirect(new URL(request.nextUrl.origin)); } } export const config = { matcher: [ /* * Match all paths except for: * 1. /api routes * 2. /_next (Next.js internals) * 3. /_static (inside /public) * 4. all root files inside /public (e.g. /favicon.ico) */ "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)", ], }; ``` ## serverAction.ts ```ts export const updateUser = async (id: any, data: any) => { const pocketbase = await initPocketBaseServer(); try { const result = await pocketbase.collection("users").update(id, data); return JSON.parse(JSON.stringify(result)); } catch (error) { return JSON.parse(JSON.stringify(error)); } }; ``` ## page.tsx ```ts export default async function Home() { const pocketbase = await initPocketBaseServer(); const userPosts = await pocketbase .collection("posts") .getFullList(); return (
{userPosts.map((post) => {return

{post.title}

})}
); } ``` ## OAuthButton.tsx (Client side) ```ts "use client"; import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { initPocketBaseClient } from "@/lib/pocketbase/client"; import { Button } from "@/components/ui/button"; interface OauthButtonProps { provider: "github" | "google"; icon: React.ReactNode; text: string; isLoading: boolean; isDisabled: boolean; setLoading: (state: boolean) => void; } export const OauthButton = ({ provider, icon, isDisabled, setLoading, isLoading, }: OauthButtonProps) => { const router = useRouter(); useEffect(() => { return () => setLoading?.(false); }, [setLoading]); return ( ); }; ```
schmt5 commented 1 year ago

@orenaksakal thank's a lot for sharing your code, i gonna try this in my application. One question, why do you rederict, if pathName starts with /auth?

orenaksakal commented 1 year ago

why do you rederict, if pathName starts with /auth?

It is optional but i find it useful to redirect user to dashboard if user is already authenticated and trying to reach auth pages

schmt5 commented 1 year ago

@orenaksakal it seems, that initPocketBaseServer() is not working correct. I see the following logs: /api/collections/users/auth-refresh: 401

{
  "errorDetails": "<nil>",
  "errorMessage": "The request requires valid record authorization token to be set."
}

I think this happen on line await pocketbase.collection("users").authRefresh();

I read to docs again and can't find any reasons why this error occurs. Do you have any idea?

orenaksakal commented 1 year ago

Do you have any idea?

No idea TBH, but it works for me on both local and vercel domain one thing I never tried is logging in as admin

EL-MEHDI-ESSAADI commented 1 year ago

@orenaksakal if I used initPocketBaseServer in multi rsc inside a page, is it not bad? because pocketbase.collection("users").authRefresh() will execute multi times

orenaksakal commented 1 year ago

is it not bad?

I think it is the same case when you use sveltekit's handle server hook see it will be executed on each request but incase its something to be prevented one way to do it would be having 3 instances middleware, server, client where middleware handles auth refreshes only and other two is for environment specific work

Note: I have been looking at pocketbase only for couple of days its likely there is huge room for improvement on what I shared

Laecherlich commented 1 year ago

response?.headers.set("set-cookie", pocketbase.authStore.exportToCookie());

I want to keep pocketbase completely on the server side but I cannot find a way to update the cookie from there, your solution with the response did not work for me.

bogdanrbucur commented 1 year ago

response?.headers.set("set-cookie", pocketbase.authStore.exportToCookie());

I want to keep pocketbase completely on the server side but I cannot find a way to update the cookie from there, your solution with the response did not work for me.

I managed using the Next.js cookies():

"use server";
import PocketBase from "pocketbase";
import { cookies } from "next/headers";
import { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies";

export interface PocketbaseLogin {
    token?: string;
    name?: string;
    email?: string;
    isAdmin?: boolean;
    error?: { message: string };
}

const pb = new PocketBase(process.env.PB_URL);

const loginPb = async ({ email, password }: { email: string; password: string }) => {
    let response: PocketbaseLogin = {};

    try {
        const authData = await pb.collection("users").authWithPassword(email, password);

        if (authData) {
            const record = authData.record;
            console.log(`User ${record.name} authenticated`);

            // set the client cookie
            cookies().set("pb_auth", pb.authStore.exportToCookie());

            response.token = authData.token;
            response.name = record.name;
            response.email = record.email;
            response.isAdmin = record.isAdmin;
        }
    } catch (err: any) {
        console.log(`${email} ${err.message}`);
        response.error = err.response;
    }
boolow5 commented 1 year ago

Here is what I have done to fix this for Next 13

In src/app/api/login/route.ts This is the `POST /api/login` endpoint that is called form the login form: ```typescript export async function POST(request: Request, { params }: any) { const body = await request.json(); const { username, password } = body; const result = await pb.collection('users').authWithPassword(username, password); if (!result?.token) { return new Response( JSON.stringify({error: 'Invalid credentials'}), { status: 401, headers: { 'Content-Type': 'application/json', }, }, ); } cookies().set("pb_auth", pb.authStore.exportToCookie()); return NextResponse.json(result); } ```
In src/app/layout.tsx ```typescript import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; const getUserDetails = async () => { try { const cookieStore = cookies(); const cookie = cookieStore.get('pb_auth'); pb.authStore.loadFromCookie(cookie?.value || ''); return pb.authStore.isValid ? pb.authStore.model : null; } catch (err) { console.log('getUserDetails error:', err); return null; } }; export default function RootLayout({ children, }: { children: React.ReactNode }) { const userDetails = use(getUserDetails()); if (!userDetails) { redirect('/login'); } return ( {children} ) } ```
In src/middleware.ts ```typescript export async function middleware(request: NextRequest) { const cookie = request.cookies.get('pb_auth'); pb.authStore.loadFromCookie(cookie?.value || ''); if (!pb.authStore.isValid) { return NextResponse.redirect(new URL('/login', request.url)) } NextResponse.next(); } ```
tsensei commented 9 months ago

Hey, I have written functions returning PocketBase instances for both Server and Client components. I have done it in a style @supabase/ssr does it for ease of understanding

For client components : This instantiates a PocketBase instance using the singleton pattern, so you can call it anywhere you want. It also syncs the cookie with localStorage, thus seamlessly responding to auth state change

createBrowserClient ```ts import { TypedPocketBase } from "@/types/pocketbase-types"; import PocketBase from "pocketbase"; let singletonClient: TypedPocketBase | null = null; export function createBrowserClient() { if (!process.env.NEXT_PUBLIC_POCKETBASE_API_URL) { throw new Error("Pocketbase API url not defined !"); } const createNewClient = () => { return new PocketBase( process.env.NEXT_PUBLIC_POCKETBASE_API_URL ) as TypedPocketBase; }; const _singletonClient = singletonClient ?? createNewClient(); if (typeof window === "undefined") return _singletonClient; if (!singletonClient) singletonClient = _singletonClient; singletonClient.authStore.onChange(() => { document.cookie = singletonClient!.authStore.exportToCookie({ httpOnly: false, }); }); return singletonClient; } ```

For server components : This instantiates a PocketBase instance for each call, you can pass it a cookieStore, in which case you will get the authStore instantiated. For static routes, you can use it without passing any cookieStore.

createServerClient ```ts import { TypedPocketBase } from "@/types/pocketbase-types"; import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import PocketBase from "pocketbase"; export function createServerClient(cookieStore?: ReadonlyRequestCookies) { if (!process.env.NEXT_PUBLIC_POCKETBASE_API_URL) { throw new Error("Pocketbase API url not defined !"); } if (typeof window !== "undefined") { throw new Error( "This method is only supposed to call from the Server environment" ); } const client = new PocketBase( process.env.NEXT_PUBLIC_POCKETBASE_API_URL ) as TypedPocketBase; if (cookieStore) { const authCookie = cookieStore.get("pb_auth"); if (authCookie) { client.authStore.loadFromCookie(`${authCookie.name}=${authCookie.value}`); } } return client; } ```

Middleware : You can have a middleware.ts at the root of your project or the src folder with matching paths for protected route. If user is not authenticated, redirect to your intended path.

middleware.ts ```ts import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { createServerClient } from "./lib/pocketbase"; // For protected pages // If auth is not valid for matching routes // Redirect to a redirect path export function middleware(request: NextRequest) { const redirect_path = "http://localhost:3000/login"; const cookieStore = cookies(); const { authStore } = createServerClient(cookieStore); if (!authStore.isValid) { return NextResponse.redirect(redirect_path); } } export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - api (API routes) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * - login (login route) * - / (root path) */ "/((?!api|_next/static|_next/image|favicon.ico|login|$).*)", ], }; ```

Note :

  1. Please note this depends on a NEXT_PUBLIC_POCKETBASE_API_URL, you must have this in your .env file
  2. For type suggestions, I am using the pocketbase-typegen by @patmood, you can generate the types using this package and place in @types/pocketbase-types.ts or a befitting path and update the import paths.
  3. Please let me know if any problem arises, I'll try my best to fix and you can keep an eye on my github repo, I will publish a NextJS & Pocketbase template soon with examples
Theonlyhamstertoh commented 9 months ago

This is really amazing work @tsensei! For the middleware.ts, /((?!api|_next/static|_next/image|favicon.ico|login|phrase|$).*), changing ^$ -> $ excluded the home page for me

tsensei commented 9 months ago

I made a starter template based on NextJS App Router (supporting SSR) - using the functionalities as mentioned in my comment

You can find it here. Clone the repo and read the README to get started.

Open a issue if you find a bug or to request any feature. Have fun building!

KweeBoss commented 8 months ago

I am curious about PocketBase. After reading your solution, I do have a question. Please forgive me if this is a silly question as I am pretty new to web development. 🙏 In production server, our domains for PB would not be localhost. Probably as this scenario, NextJS is on -p 3000 and PB on -p 8090 Then we might bind -p 443 to NGINX as reverse proxy. So we need to split PB to a subdomain. NextJS App - https://mydoman.com PocketBase - https://pb.mydomain.com

In this case, I want to understand how PocketBase cookie from authStore.exportToCookie() sign the domain? If this export the cookie domain as “.mydomain.com”, since (.) can share to root domain, it should be accessible from my domain.com. If it export cookie domain as “api.mydomain.com”, how can I access the cookie from root domain. (This is my understanding and please correct me if it is incorrect)

How does it work actually with this solution ? I really wish to learn that part a little more so the I can secure route from server side. Marry Christmas to you!

tsensei commented 8 months ago

@KweeBoss, as you need the cookie accessible to the root domain, you can just export it with the root domain and it should be accessible too all subdomains if I recall correctly.

KweeBoss commented 8 months ago

Thank you so much @tsensei 🙏. I have found it. export interface SerializeOptions { encode?: (val: string | number | boolean) => string, maxAge?: number, domain?: string, path?: string, expires?: Date, httpOnly?: boolean, secure?: boolean, priority?: string, sameSite?: boolean|string, }

bentrynning commented 8 months ago

Hi I have issues with request not having Authorization header added to PB request from server. After logging in as admin and having set the cookie with the logged in token.

I have this setup for the pocketbase server client on my server actions

async function initPocketbase() {
  const pb = new PocketBase("http://127.0.0.1:8090");

  // load the store data from the request cookie string
  const authCookie = cookies().get("pb_auth");
  if(authCookie) {
    pb.authStore.loadFromCookie(`${authCookie.name}=${authCookie.value}`);

    console.log("Should be here", pb.authStore.token)

    try {
      // get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
      pb.authStore.isValid && (await pb.admins.authRefresh());
    } catch (_) {
      // clear the auth store on failed refresh
      pb.authStore.clear();
      redirect("/login");
    }

  }

  // send back the default 'pb_auth' cookie to the client with the latest store state
  pb.authStore.onChange((token, model) => {
    console.log("valid", pb.authStore.isValid)
    console.log("token", token);
    cookies().set("pb_auth", pb.authStore.token, {
      httpOnly: true
    });
  });

  return pb;
}

export default initPocketbase;

Cookie is available and has this pb_auth=eyJhbGciOiJIUzI1N... format when used in loadFromCookie But it looks like loadFromCookie never resolves to setting the token and updating the model in Auth Store.

I tried with both admin auth and user auth.

The components that uses the code above lookes like this

export async function getOrders() {
  const pb = await initPocketbase()

  const records = await pb.collection("orders").getFullList({
    sort: "-created",
  });

  return records;
}

The orders endpoint for now only allows admins, but in the PB logs all request are done as quest.

ganigeorgiev commented 8 months ago

@bentrynning Are you sure that authCookie.value is a raw serialized cookie string and not the token or the json object that contains the token?

Note you don't have to use pb.authStore.loadFromCookie/exportToCookie if you are using the Next.js cookies abstraction. To load a token directly into the store you can call pb.authStore.save(token).

In any case, I'm closing this issue as it is starting to become too off-topic and there isn't any clear actionable item here.

I've tried to check if anything has changed in Next.js couple months ago and unfortunately I wasn't able to find an easy solution for sharing the SDK AuthStore state between server components and I'm reaching a point where I'd rather not recommend using PocketBase with Next.js as it feels too much work for very little benefit, at least to me.

If someone has a suggestion without having to maintain special Next.js helpers, feel free to open a PR with an example for the README.

bentrynning commented 8 months ago

ganigeorgiev Thanks for the reply, and sorry for adding just another of topic question to this thread. Next only gives you the cookie name and cookie value trough the cookie api. So it is not a serialized cookie string. But just saw all the other examples and looked like it was working for them with just a cookieName=cookieValue (token) string.

I'll try to set the token directly in the store then! Thanks for the tip.

By the way thanks for the tremendous nice work you have done with PocketBase <3 very cool project!