Redirection adds '#' to end of URL

I am using Slim 4 with PHP 7.3 on a Debian 10 VM instance in the Google Cloud.

I have written a custom Auth Handler and Auth Controller that relies of Microsoft MSAL and Graph for User Authentication.

Whenever the authentication process cycles through the MSAL auth process, it sends the browser back to the /auth/callback route. The redirect in this route gets a hash character appended to it. I have added logging immediately before the redirect, and the URL variable $redirectURL does not contain the hash character.

Below are the relevant snippets of my code.

UserAuthMiddleware

class UserAuthMiddleware implements Middleware
{
    private $session;
    private $user;

    public function __construct(Session $session, User $user)
    {
        $this->session = $session;
        $this->user = $user;
    }

    public function __invoke(Request $request, Handler $handler): Response
    {
        return $this->process($request, $handler);
    }

    public function process(Request $request, Handler $handler): Response
    {
        if ($this->session->exists('user') && false !== $this->session->get('user')) {
            $request = $request->withAttribute('user', $this->user);
            return $handler->handle($request);
        }
        
        $this->session->set('userauthroute', $request->getUri()->getPath());
        $factory = new ResponseFactory;
        $response = $factory->createResponse();
        return $response
            ->withStatus(302)
            ->withHeader('Location', RouteContext::fromRequest($request)->getRouteParser()->urlFor('login'));
    }
}

UserAuthController

class UserAuthController
{
    private $container;
    private $responseFactory;
    private $session;

    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
        $this->responseFactory = new ResponseFactory();
        $this->session = $container->get('session');
    }

    public function login(Request $request, Response $response, $args): Response
    {
        $provider = $this->getProvider($request);
        $authURL = $provider->getAuthorizationUrl(['scope' => ['User.Read']]);

        $this->session->set('oauth2state', $provider->getState());
        $this->session->set('user', false);
        
        return $this->responseFactory->createResponse()
            ->withHeader('Location', $authURL)
            ->withStatus(302);
    }

    public function callback(Request $request, Response $response, $args): Response
    {
        $params = $request->getQueryParams();

        if (!array_key_exists('code', $params) || !array_key_exists('state', $params)) {
            throw new UserAuthException("Invalid Callback Request");
        }

        if (!$this->session->exists('oauth2state') || $this->session->get('oauth2state') !== $params['state']) {
            throw new UserAuthException("Callback State Error");
        }
        $this->session->delete('oauth2state');

        $provider = $this->getProvider($request);
        $token = $provider->getAccessToken('authorization_code', ['code' => $params['code']]);

        $graph = new Graph();
        $graph->setAccessToken($token->getToken());
        $user = $graph->createRequest("GET", "/me")->setReturnType(Model\User::class)->execute();
        $this->session->set('user', $user->getMail());

        if ($this->session->exists('userauthroute')) {
            $redirectURL = $this->session->get('userauthroute');
            $this->session->delete('userauthroute');
        } else {
            $redirectURL = RouteContext::fromRequest($request)->getRouteParser()->urlFor('home');
        }

        $logger = LoggerFactory::create('info');
        $logger->info($redirectURL);

        return $this->responseFactory->createResponse()
            ->withHeader('Location', $redirectURL)
            ->withStatus(302);
    }

    public function logout(Request $request, Response $response, $args): Response
    {
        $this->session->set('user', false);
        return $this->container->get('view')->render($response, 'logout.twig');
    }

    private function getProvider($request): Provider
    {
        $uri = $request->getUri();
        $callbackUrl = RouteContext::fromRequest($request)->getRouteParser()->fullUrlFor($uri, 'callback');
        $options = array_merge($this->container->get('MSAL_Config'), ['redirectUrl' => $callbackUrl]);
        return new Provider($options);
    }
}

routes.php

    // MSAL User Auth Routes
    $app->group('/auth', function (RouteCollectorProxy $group) use ($app) {
        $group->get('/login', [UserAuthController::class, 'login'])->setName('login');
        $group->get('/callback', [UserAuthController::class, 'callback'])->setName('callback');
        $group->get('/logout', [UserAuthController::class, 'logout'])->setName('logout')->add(TwigMiddleware::createFromContainer($app));
    });

I will be grateful if anyone can help me figure out why and where the hash character is being appended, and what I can do to prevent this from happening.

I have discovered that the Microsoft MSAL service is appending the “#” character to the callback url when it returns after processing the authentication.
https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/1579

So, instead of sending back to https://<domain>/auth/callback, it is sending back to https://<domain>/auth/callback#

I have also tested that when Slim processes a redirect response where the current request url contains a “#” at the end of the url, it adds the hash to the redirect url as well.

Any guidance about how to remove the fragment portion in a redirect when generating the response would be greatly appreciated.

Hi @abid-canadavisa Have you tried this? Removing anchor (#hash) from URL.

Example:

$callbackUrl = strstr($callbackUrl, '#', true);

Hello Odan,

Thanks so much for your suggestion. But none of my variables with URLs in them have a hash character. So, using functions to strip the string of the hash fragment does not affect the outcome.

Consider the following code in the routing:

$app->get('/home', function (Request $request, Response $response, $args) {
    return $this->get('view')->render($response, 'home.twig'));
})->setName('home');

$app->get('/hello', function (Request $request, Response $response, $args) {
    $url = RouteContext::fromRequest($request)->getRouteParser()->urlFor('home');
    return $response->withHeader('Location', $url)->withStatus(302);
});

If I enter https://<domain>/hello into browser, I am redirected to https://<domain>/home. But, if I enter https://<domain>/hello# into the browser, I am redirected to https://<domain>/home#, even though the $url variable in above code never contains ‘#’ character in it.

This implies that the routing or response emitter at the framework level is capturing the fragment from the Request, even though the url in Response does not have it. Do you have any ideas or suggestions about how I can generate a Redirect Response that ignores the hash character present in the Request?

The behavior you are describing is a browser (client) specific behavior. The browser does not send the fragment identifier (hash mark #) to the webserver (php, slim) over http. You can see it if you open the F12 developer toolbar > Network.

Chrome will append the fragment identifier to the new URI, which generally confuses the user because they end up with the wrong resource.

Until now, Firefox does not add the fragment identifier to the new URI.

Just to let you know, here you can find a proper specification by w3c defining how all should behave:

I understand now. Thank you, Odan, for the clarification.