symfony / mercure

The Mercure Component allows to easily push updates to web browsers and other HTTP clients using the Mercure protocol.
https://symfony.com/doc/current/components/mercure.html
MIT License
413 stars 39 forks source link

401 Unauthorized with subdomain #116

Closed ytilotti closed 4 months ago

ytilotti commented 5 months ago

Hi all.

I have an issue 401 Unauthorized with subdomains on Symfony mercure. If I use theses domains, all is fine:

But with these following domains, the cookie is not set to the request headers for mercure:

More informations:

I miss something or a real issue? Seems to be fixed here #75

ytilotti commented 4 months ago

The problems appear when mercure-turbo-stream is set to true.

"@symfony/ux-turbo": {
    "turbo-core": {
        "enabled": true,
        "fetch": "eager"
    },
    "mercure-turbo-stream": {
        "enabled": true,
        "fetch": "eager"
    }
}
dunglas commented 4 months ago

I don't know if it's the root cause, but .dev is a real TLD with HSTS settings etc. It is known to cause issues in dev.

Could you try with .test or .localhost, which are designed for dev use cases instead?

ytilotti commented 4 months ago

Thanks for your answer @dunglas but same issue with Caddyfile.test.

The calls: list

Main page /chat: maincall

First call mercure: call1mercure

Second call mercure: call2mercure We can see on the second call that the cookie is not sent.

If I call directly mercure, it's ok: image

I will add some code to maybe have more informations.

Dockerfile:

mercure:
    image: dunglas/mercure:v0.14.1
    volumes:
      - ./.docker/mercure/Caddyfile:/etc/caddy/Caddyfile.test:ro
      - ./.docker/certs/:/etc/caddy/certs/:ro
    environment:
      MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisSecretItNeedsToBeMuchLonger!}
      MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisSecretItNeedsToBeMuchLonger!}
    command: /usr/bin/caddy run --config /etc/caddy/Caddyfile.test
    ports:
      - target: 3001
        published: 3001
        protocol: tcp
    hostname: mercure.xxxxxxxxxxxxx.local

Caddyfile:

{
    {$GLOBAL_OPTIONS}
}

{$SERVER_NAME:mercure.xxxxxxxxxxxxx.local:3001}

log {
    format filter {
        wrap console
        fields {
            uri query {
                replace authorization REDACTED
            }
        }
    }
}

tls /etc/caddy/certs/xxxxxxxxxxxxx.crt /etc/caddy/certs/xxxxxxxxxxxxx.key

{$EXTRA_DIRECTIVES}

route {
    encode zstd gzip

    mercure {
        # Transport to use (default to Bolt)
        transport_url {$MERCURE_TRANSPORT_URL:bolt://mercure.db}
        # Publisher JWT key
        publisher_jwt {$MERCURE_PUBLISHER_JWT_KEY} {$MERCURE_PUBLISHER_JWT_ALG}
        # Subscriber JWT key
        subscriber_jwt {$MERCURE_SUBSCRIBER_JWT_KEY} {$MERCURE_SUBSCRIBER_JWT_ALG}
        # Extra directives
        cors_origins https://extranet.xxxxxxxxxxxxx.local https://xxxxxxxxxxxxx.local
        {$MERCURE_EXTRA_DIRECTIVES}
    }

    respond /healthz 200
    respond "Not Found" 404
}

.env:

###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=https://mercure.xxxxxxxxxxxxx.local:3001/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://mercure.xxxxxxxxxxxxx.local:3001/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisSecretItNeedsToBeMuchLonger!"
###< symfony/mercure-bundle ###

mercure.yaml:

mercure:
    hubs:
        default:
            url: '%env(MERCURE_URL)%'
            public_url: '%env(MERCURE_PUBLIC_URL)%'
            jwt:
                secret: '%env(MERCURE_JWT_SECRET)%'
                publish: '*'

controllers.json:

{
    "controllers": {
        "@symfony/ux-turbo": {
            "turbo-core": {
                "enabled": true,
                "fetch": "eager"
            },
            "mercure-turbo-stream": {
                "enabled": true,
                "fetch": "eager"
            }
        }
    },
    "entrypoints": []
}

ChatController.php:

<?php
namespace App\Controller\Admin;

use App\Controller\Controller as AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Mercure\Discovery;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Annotation\Route;

#[Route('/pro_admin201')]
class ChatController extends AbstractController
{
    #[Route('/chat', name: 'admin_chat')]
    public function chat(
        Request $request,
        HubInterface $hub,
        Discovery $discovery,
        Authorization $authorization,
    ): Response
    {
        $form = $this->createFormBuilder()
            ->add('message', TextType::class, ['attr' => ['autocomplete' => 'off']])
            ->add('send', SubmitType::class)
            ->getForm();

        $emptyForm = clone $form; // Used to display an empty form after a POST request
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();

            // 🔥 The magic happens here! 🔥
            // The HTML update is pushed to the client using Mercure
            $hub->publish(new Update(
                'https://example.com/books/1',
                $this->renderView('admin/chat/message.stream.html.twig', ['message' => $data['message']]),
            ));

            // Force an empty form to be rendered below
            // It will replace the content of the Turbo Frame after a post
            $form = $emptyForm;
        }

        return $this->render('admin/chat/index.html.twig', [
            'form' => $form,
        ]);
    }
}

chat/index.html.twig:

{% extends "admin/layout.html.twig" %}

{% block title %}Messages {{ parent() }}{% endblock %}

{% block javascripts %}
    <script>
        const eventSource = new EventSource("{{ mercure('https://example.com/books/1', { subscribe: ['https://example.com/books/1'] })|escape('js') }}", {
            withCredentials: true
        });
    </script>
{% endblock %}

{% block body %}
    <h1>Chat</h1>

    <div id="messages" {{ turbo_stream_listen('https://example.com/books/1') }}>
        {#
        The messages will be displayed here.
        "turbo_stream_listen()" automatically registers a Stimulus controller that subscribes to the "chat" topic as managed by the transport.
        All connected users will receive the new messages!
        #}
    </div>

    <turbo-frame id="message_form">
        {{ form(form) }}

        {#
        The form is displayed in a Turbo Frame, with this trick a new empty form is displayed after every post,
        but the rest of the page will not change.
        #}
    </turbo-frame>
{% endblock %}

chat/message.stream.html.twig:

<turbo-stream action="append" target="messages">
    <template>
        <div>{{ message }}</div>
    </template>
</turbo-stream>
dunglas commented 4 months ago

The cookie is missing on the second call. This is because the default Turbo Stream controller doesn't support cookies: https://github.com/symfony/ux-turbo/blob/2.x/assets/src/turbo_stream_controller.ts#L43

Currently, you have to use a custom Turbo Stream controller that sets the withCredentials: true attributes to EventSource, but that would be nice to support this natively in UX Turbo.

ytilotti commented 4 months ago

so @dunglas, this code is useless?

{% block javascripts %}
    <script>
        const eventSource = new EventSource("{{ mercure('https://example.com/books/1', { subscribe: ['https://example.com/books/1'] })|escape('js') }}", {
            withCredentials: true
        });
    </script>
{% endblock %}
dunglas commented 4 months ago

@ytilotti yes.

The EventSource instance is provided in the stimulus controller.

ytilotti commented 4 months ago

With a custom Turbo Stream controller it's working. It's really strange that this is not implemented by default because it's not in line with the documentation:

Keep in mind that you can use all features provided by Symfony Mercure, including private updates (to ensure that only authorized users will receive the updates) and async dispatching with Symfony Messenger.

It's wrong. There is a problem on the documention, no?

dunglas commented 4 months ago

You can using a custom controller... But instead of fixing the docs we should fix the code.

I'll try to open a PR (but don't hesitate to do it if you prefer).

ytilotti commented 4 months ago

There are different ways to fix this:

With the others functions on Mercure, the boolean seems to be the most logical. Link to https://github.com/symfony/ux/issues/291. If you give the way I can do the PR @dunglas. Thanks for your time anyway.

dunglas commented 4 months ago

I would go with a new object argument passed as a parameter of turbo_stream_listen() that will then be encoded as a JS object passed to EventSource. This will also allow us to leverage the extra options added by polyfills (like custom headers).

Closing this issue in favor of https://github.com/symfony/ux/issues/291.

tibobaldwin commented 4 months ago

But how to integrate the mercure() Twig function in the stimulus controller?