dai-shi / waku

⛩️ The minimal React framework
https://waku.gg
MIT License
3.84k stars 99 forks source link

XX_auth example #576

Open aheissenberger opened 1 month ago

aheissenberger commented 1 month ago

I am looking into creating a simple auth example with a home page and some protected sub routes.

Do I miss something, but:

  1. I could not find any docs on how to define a protected (conditional) route which covers all sub routes with waku route or a guard hook like https://vike.dev/guard .
  2. will need to define an API endpoint to catch the callbacks in the OAuth process (Open ID)

I know that #329 was post boned, but today using an OAuth 2 authorization flow is more common than using a password database.

I still would start if I get a hint on how to deal with 1. and would use Lucia as the auth layer in the middleware. Maybe I can use the middleware to implement the callback and do not need #329.

dai-shi commented 1 month ago

Thanks for asking. No, you don't miss anything. I've been thinking some auth example would be necessary too.

I'm not sure if middleware will be a final solution (and I don't think #329 will be for auth anyway), but yes, it's the only solution for now. Let's see how much we can go with it.

Let's say if we somehow checked credential in middleware, what would be the expected response? Would be easy for redirect for HTML, and 401 Forbidden for RSC, but too limited?

aheissenberger commented 1 month ago

Currently there are two common approaches when going to a page which needs auth:

  1. User will be redirected to a login page url - this will require to store the original request in the context to allow a recall / redirect after the login
  2. Login will replace the content of the target page and a simple reload after the login will provide the content of the target url

The middleware will check the credentials and will provide user data in the context. It is than up to the server component to check this context and decide based on the permissions of the user what to do - e.g. show alternative content, a login or redirect to a login page.

I could try a prototype - or do you think I should wait for a later release of WAKU?

dai-shi commented 1 month ago

No, it's a good timing to try and learn what's good and bad.

dai-shi commented 1 month ago

FYI, check this thread: https://twitter.com/sebmarkbage/status/1765414733820129471

aheissenberger commented 1 month ago

FYI, check this thread: https://twitter.com/sebmarkbage/status/1765414733820129471

I do not now any framework where auth is not handled at the middleware. The tweet does not offer a solution as you cannot show parts of the layout and check only at the data layer for permission.

My current implementation ist doing this in the middleware:

  1. get the session ID from the cookie
  2. get the User by the session from the database

He is right, that accessing a database in the middleware is a performance problem but this is only the case for sessions which are database bound. Token based session do not need any database.

The routing of the authenticated or not authenticated users should be handled by the router or in the page component. So currently redirecting and 404 are all not handled by the middleware.

aheissenberger commented 1 month ago

@dai-shi How can I read and write to the context from a server function.

Currently I get this error when I use the getContext function:

Error: [Bug] No render context found
    at Module.getContext (/waku/packages/waku/dist/server.js:37:15)
    at register (/waku/examples/xx_auth/src/func.ts:25:43)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async file:///waku/packages/waku/dist/lib/renderers/rsc-renderer.js:32:25
'use server';
import { pool } from "./lib/db.js";
import { lucia } from "./lib/auth.js";
import { generateId ,Session, User} from "lucia";
import { Argon2id } from "oslo/password";
import {getContext}  from 'waku/server';

export const register = async (formData: FormData) => {
    const name = formData.get('name');
    const email = formData.get('email');
    const password = formData.get('password');

    const hashedPassword = await new Argon2id().hash(password);
    const userId = generateId(15);
    try {
        const db = await pool.getConnection();
        await db.execute(`INSERT INTO user (id, username, password,email,name) VALUES (?, ?, ?,?,?)`, [userId, email, hashedPassword, email, name]);
        const session = await lucia.createSession(userId, {});

        const context = getContext<{ session: Session,user: User }>();

        context.session = session;

    } catch (e) {
        console.log(e);
    }
};
dai-shi commented 1 month ago

How do you call the register function? It needs to be inside React so to say.

aheissenberger commented 1 month ago

except for the context - my code is working as expected as a server function:

/// <reference types="react/canary" />
/// <reference types="react-dom/canary" />

'use client';

import { useFormStatus } from 'react-dom';

const RegisterButton = () => {
    const { pending } = useFormStatus();
    return (
        <>
            <button disabled={pending} type="submit">
                Register
            </button>
            {pending ? 'Pending...' : null}
        </>
    );
};
const RegistartionForm = ({ register }) => {
    return (
        <div>
            <h1>Registration Form</h1>
            <form action={register} >
                <div>
                    <label htmlFor="name">Name:</label>
                    <input type="text" id="name" name="name" required />
                </div>
                <div>
                    <label htmlFor="email">Email:</label>
                    <input type="email" id="email" name="email" required />
                </div>
                <div>
                    <label htmlFor="password">Password:</label>
                    <input type="password" id="password" name="password" required />
                </div>
                <div>
                    <RegisterButton />
                </div>
            </form>
        </div>
    )
}
export default RegistartionForm

and here is the root server component:

import RegistrationForm from "../components/RegistrationForm.js";
import { register } from "../func.js";

const RegistrationPage = () => {
    return (

                        <RegistrationForm register={register} />

    );
}
export default RegistrationPage;
dai-shi commented 1 month ago

Hmm, it looks fine to me. You may need to find a difference from 05_actions example. Wait, that example uses rerender but not getContext. But, it should be the same situation...

aheissenberger commented 1 month ago

There is no exiting example using getContext in a server function or a server component. How can I help to find this problem?

dai-shi commented 1 month ago

See #584

dai-shi commented 1 month ago

587 will fix.

daanlenaerts commented 2 weeks ago

I have been building a Middleware-based example to facilitate cookie-based authentication. I’m not sure, however, what the best way to protect routes would be. Does any of you have a suggestion?

Checking the request pathname against a set of protected routes is not great, as this doesn’t cover server components that are fetched by client-side React.

With regards to auth in server actions, is there already a way to set a 401 header if the user is not authorized?

dai-shi commented 2 weeks ago

With regards to auth in server actions, is there already a way to set a 401 header if the user is not authorized?

I'm not sure if this is going to be a proper way, but if a thrown object has .statusCode property, it will use it as the status code in the response.

daanlenaerts commented 2 weeks ago

Great, I’ll experiment with that! It’s especially useful in server actions.

The main thing I’m stuck on is properly validating route access for server components. I have tried checking access inside of a server component, but that just doesn’t feel right. Throwing an error at that point also crashes the server. But at any rate, existing frameworks don’t seem to do it this way either. It seems most natural to me to check access on a route level, through the route configuration or a middleware.

Do you have any direction you would like this to go in? If I figure out a good way to do this I’d love to contribute it, as an example or a low-level integrated API in Waku.

dai-shi commented 2 weeks ago

It seems most natural to me to check access on a route level, through the route configuration or a middleware.

https://github.com/dai-shi/waku/issues/576#issuecomment-1983495551 I'm really not sure how it should be.

But, for now, if you need a route level access check, you can try it with middleware. It's the only option.

aheissenberger commented 2 weeks ago

@daanlenaerts access logic for a server function is similar to validation. What you need there is a context to the current authenticated user role which you then add to your ORM call which also handles the access rights. Converting a session token in a cookie to a user needs to happen in the middleware.

daanlenaerts commented 2 weeks ago

It seems most natural to me to check access on a route level, through the route configuration or a middleware.

#576 (comment) I'm really not sure how it should be.

But, for now, if you need a route level access check, you can try it with middleware. It's the only option.

Makes sense!

@daanlenaerts access logic for a server function is similar to validation. What you need there is a context to the current authenticated user role which you then add to your ORM call which also handles the access rights. Converting a session token in a cookie to a user needs to happen in the middleware.

For sure, I've got that working already. The DX for it isn't great though at the moment, I'll have to figure out a way to improve what happens when access is not granted. Possible the .statusCode error property @dai-shi mentioned will go a long way.

aheissenberger commented 2 weeks ago

For sure, I've got that working already. The DX for it isn't great though at the moment, I'll have to figure out a way to improve what happens when access is not granted. Possible the .statusCode error property @dai-shi mentioned will go a long way.

Have a look at graphQL or tRPC - both of them handle this great. You will need a custom Error Class you throw on the server and handle this on the client.

t6adev commented 1 week ago

Hi there, I just dropped it. It almost works well but WIP. https://twitter.com/t6adev/status/1783504614370967937

daanlenaerts commented 6 days ago

This might be a little off topic, but @t6adev, I was reading through your code and saw you are using Scrypt instead of Argon2 as a hashing algorithm. In my experiments I also noticed Argon2 is not working when the project is built, neither is Bcrypt.

Do you have any ideas or pointers on how to make Vite work with Argon2 or Bcrypt? (Bcrypt doesn't seem to work as the Node crypto module cannot be resolved.)

t6adev commented 3 days ago

Hi, @daanlenaerts ! I don't have any consideration about what hash algorithm I should use. Currently, I'm just following it:

Argon2id is recommended, and if it's not possible, scrypt is recommended.

https://oslo.js.org/reference/password/

Anyway, I resolved how to set it up in vite.config.ts to use Argon2,

/** @type {import('vite').UserConfig} */
export default ({ mode }: { mode: string }) => ({
  ...(mode === 'development'
    ? { optimizeDeps: { exclude: ['oslo/password'] } }
    : {
        ssr: {
          external: ['oslo/password'],
        },
      }),
});