h2oai / wave

Realtime Web Apps and Dashboards for Python and R
https://wave.h2o.ai
Apache License 2.0
3.9k stars 323 forks source link

User-level state shared accross browser (and potentially computers) #2240

Closed aranvir closed 5 months ago

aranvir commented 5 months ago

Wave SDK Version, OS

Wave 1.0.0 (and v0.26.3) Windows 10 Python

Actual behavior

When using q.app.user to share state for a user session, this state is actually also shared across browser instances of the same computer. There might even be the case that it is shared across computers (see https://github.com/h2oai/wave-apps/pull/118#issuecomment-1910467589)

Expected behavior

As described in https://wave.h2o.ai/docs/state, it is expected that the state is shared across tabs of a browser session (clients of the same user). Opening a different browser session (incognito mode, chrome instead of firefox, different computer) should give each user "fresh state".

Steps To Reproduce

I provided sample code below which lets you set a user name that is stored in q.user.name. I tested this with Firefox, Firefox Incognito and Chrome. In my home network, I could not figure out how to serve waved on 0.0.0.0 and running a proxy also didn't really work (probably my firewall, idk). I did test it with a browser running from WSLv2 on the same machine, accessing the app via the proxy, so that "should" be like a different computer since it's not originating from localhost. But I did not manage to test this with actually separate computers in the same network.

However, as mentioned before, @HugoP reported that the user state was shared on different computers for the same app.

The example below is with run_on but I also tested it with handle_on for version 1.0.0 and version 0.26.3 just in case.

from typing import Optional, List
from h2o_wave import main, app, Q, ui, on, data, run_on

def add_card(q: Q, name, card) -> None:
    q.client.cards.add(name)
    q.page[name] = card

# Remove all the cards related to navigation.
def clear_cards(q: Q, ignore: Optional[List[str]] = []) -> None:
    print("Clearing cards")
    if not q.client.cards:
        print("No cards")
        return

    for name in q.client.cards.copy():
        if name not in ignore:
            del q.page[name]
            q.client.cards.remove(name)

@on('#page1')
async def page1(q: Q):
    clear_cards(q)
    print("Loading page1")
    q.page['sidebar'].value = '#page1'

    add_card(q, 'user_data', ui.form_card('horizontal', items=[
        ui.text_l(f"Current name: {q.user.name}"),
        ui.textbox('enter_name', 'Enter user name'),
        ui.button('change_name', 'Change name', primary=True)
    ]))

@on('change_name')
async def change_name(q: Q):
    q.user.name = q.args.enter_name
    await page1(q)

async def init(q: Q) -> None:
    q.page['meta'] = ui.meta_card(box='', layouts=[ui.layout(breakpoint='xs', min_height='100vh', zones=[
        ui.zone('main', size='1', direction=ui.ZoneDirection.ROW, zones=[
            ui.zone('sidebar', size='250px'),
            ui.zone('body', zones=[
                ui.zone('header'),
                ui.zone('content', zones=[
                    # Specify various zones and use the one that is currently needed. Empty zones are ignored.
                    ui.zone('horizontal', size='1', direction=ui.ZoneDirection.ROW),
                    ui.zone('centered', size='1 1 1 1', align='center'),
                    ui.zone('vertical', size='1'),
                    ui.zone('grid', direction=ui.ZoneDirection.ROW, wrap='stretch', justify='center')
                ]),
            ]),
        ])
    ])])
    q.page['sidebar'] = ui.nav_card(
        box='sidebar', color='primary', title='My App', subtitle="Let's conquer the world!",
        value=f'#{q.args["#"]}' if q.args['#'] else '#page1',
        image='https://wave.h2o.ai/img/h2o-logo.svg', items=[
            ui.nav_group('Menu', items=[ui.nav_item(name='#page1', label='Home'),]),
        ])
    q.page['header'] = ui.header_card(
        box='header', title='', subtitle='',
    )
    # If no active hash present, render page1.
    if q.args['#'] is None:
        await page1(q)

@app('/')
async def serve(q: Q):
    if not q.client.initialized:
        # Run only once per client connection (e.g. new tabs by the same user).
        q.client.cards = set()
        await init(q)
        q.client.initialized = True
        q.client.new = True  # Indicate that client connected for the first time
        print("client initialized")
    if not q.user.initialized:
        q.user.initialized = True
        q.user.name = ""
        print("user initialized")

    print("ARGS BUFFER:\n", q.args)
    print("CLIENT BUFFER:\n", q.client)
    print("USER BUFFER:\n", q.user)

    await run_on(q)
    await q.page.save()
mturoci commented 5 months ago

Hey @aranvir. q.user assumes the presence of an OIDC authenticated user (maybe our docs need improvement). You can see how it's scoped here. The example you linked uses a custom JWT auth.

aranvir commented 5 months ago

I see, thank you for the clarification. Then I implemented this JWT approach with the wrong assumption. But I also guess it's then not even possible to use user state reasonably without OIDC, right? Or rather - I assume that user state is then also shared across all users like app state.

Does it then even make sense to keep the JWT example, if it can't work?

mturoci commented 5 months ago

But I also guess it's then not even possible to use user state reasonably without OIDC, right?

You would need to implement your own version of q.user: A global dict keyed by JWT token usernames/subjects whatever. The implementation would be very similar to the one I linked above. Another option would add it natively to Wave somehow.

I assume that user state is then also shared across all users like app state.

Correct. Non-authenticated users have auth.subject set to anon. Since all users have the same key so it basically equals to q.app in that case.

Does it then even make sense to keep the JWT example, if it can't work?

I would say it does. It only can't work with q.user. The point of the example is to show that JWT is possible (login screen) if you put in the effort. In real-world apps, user-state is stored in an external DB anyway, not in-memory.

aranvir commented 5 months ago

Thanks again for the feedback! That gives me some food for thought...

mturoci commented 5 months ago

Also, since this is a very niche feature (most folks go with OIDC), it won't be prioritized, but I am fine with accepting a contribution as long as it doesn't make the code much more complex.

Feel free to ping me in case of any more questions! Covnerting this into a GH discussion.