Created
September 8, 2023 17:07
-
-
Save marcguyer/afc9edffc0ac264e28f1e5be4e712d7f to your computer and use it in GitHub Desktop.
PSR7 Middleware supporting OAuth2 redirect flows
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace MyAuth\Middleware; | |
use \RuntimeException; | |
use Psr\Http\Message\ResponseInterface; | |
use Psr\Http\Message\ServerRequestInterface; | |
use Psr\Http\Server\MiddlewareInterface; | |
use Psr\Http\Server\RequestHandlerInterface; | |
use Psr\Log\LoggerInterface; | |
use SplStack; | |
use Mezzio\Authentication\UserInterface; | |
use Mezzio\Session\SessionMiddleware; | |
use Laminas\Uri\UriFactory; | |
/** | |
* Middleware to manage various redirect locations and deep links | |
*/ | |
class RedirectMiddleware implements MiddlewareInterface | |
{ | |
private LoggerInterface $logger; | |
private SplStack $stack; | |
/** | |
* Value used to indicate that location redirect management is deferred | |
* to this middleware. | |
*/ | |
public const LOCATION_PLACEHOLDER = 'location_placeholder'; | |
private array $config; | |
public function __construct(array $config, LoggerInterface $logger) | |
{ | |
$this->config = $config; | |
$this->logger = $logger; | |
// start with a fresh stack | |
$this->stack = new SplStack(); | |
} | |
public function process( | |
ServerRequestInterface $request, | |
RequestHandlerInterface $handler | |
): ResponseInterface { | |
// session is required to store redirect values across oauth2 request flows | |
if ( | |
null === $session = $request | |
->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE) | |
) { | |
throw new \RuntimeException('Session is required by this middleware'); | |
} | |
// merge the stack from session | |
if ($session->has(self::class)) { | |
$sessionStack = unserialize( | |
$session->get(self::class), | |
['allowed_classes' => [SplStack::class]] | |
); | |
$this->logger->debug( | |
'Redirect stack found in session', | |
[ | |
'session_count' => count($sessionStack), | |
'current_count' => count($this->stack) | |
] | |
); | |
$this->stack = $sessionStack; | |
$this->logger->debug('Redirect stack', $this->toArray()); | |
} | |
// add this object as a request attribute so later | |
// middlewares/handlers can use it | |
$request = $request->withAttribute(self::class, $this); | |
// if the request has a 'fwd' param, remember it for later (deep link) | |
// and remove it from the request | |
$request = $this->detectDeepLink($request); | |
// run the next handler and get the response | |
// additional redirect locations could be added to the stack | |
// anywhere in the rest of the pipeline | |
$response = $handler->handle($request); | |
// remember the current stack | |
$session->set(self::class, serialize($this->stack)); | |
// if user is not authenticated, we do nothing | |
if (null === $request->getAttribute(UserInterface::class)) { | |
$this->logger->debug('No authenticated user. Nothing to do.'); | |
return $response; | |
} | |
// no location header so nothing to manage | |
if (!$response->hasHeader($this->config['header_name'])) { | |
$this->logger->debug('No location header. Nothing to do.'); | |
return $response; | |
} | |
// if the location header does not contain the special value | |
// we will not touch it | |
if (self::LOCATION_PLACEHOLDER !== $response->getHeader($this->config['header_name'])[0]) { | |
$this->logger->debug( | |
'Location header is not a placeholder. Nothing to do.', | |
['location_header' => $response->getHeader($this->config['header_name'])[0]] | |
); | |
return $response; | |
} | |
return $response->withHeader($this->config['header_name'], $this->getRedirect($request)); | |
} | |
public function push(string $key, string $redirect, bool $replace = true): void | |
{ | |
$uri = $this->getValidUri($redirect); | |
$this->logger->debug('Redirect stack', $this->toArray()); | |
$this->logger->debug('Pushing location onto the stack', [$key => $uri]); | |
if ($replace) { | |
$this->stack->rewind(); | |
$this->logger->debug('Starting with', ['key' => $this->stack->key()]); | |
foreach ($this->stack as $idx => $element) { | |
$this->logger->debug('Element', [$idx => $element]); | |
if (isset($element[$key])) { | |
$this->logger->info('Removing element', [$idx => $element]); | |
$this->stack->offsetUnset($idx); | |
break; | |
} | |
} | |
} | |
$this->stack->push([$key => $uri]); | |
$this->logger->debug('Redirect stack', $this->toArray()); | |
} | |
public function toArray(): array | |
{ | |
$arr = []; | |
$this->stack->rewind(); | |
foreach ($this->stack as $e) { | |
$arr[] = $e; | |
} | |
return $arr; | |
} | |
public function getStack(): SplStack | |
{ | |
return $this->stack; | |
} | |
private function getRedirect(ServerRequestInterface $request): string | |
{ | |
// no redirect info in the session, so redirect to default loc | |
if ($this->stack->isEmpty()) { | |
$redirect = $request->getAttribute(UserDefaultRedirectMiddleware::REQUEST_ATTRIBUTE); | |
$this->logger->debug( | |
'Nothing in the stack. Using the default.', | |
['default_redirect' => $redirect] | |
); | |
return $redirect; | |
} | |
$redirect = $this->stack->pop(); | |
// after modifying the stack, we save the new version to the session | |
$session = $request | |
->getAttribute(SessionMiddleware::SESSION_ATTRIBUTE); | |
if ($this->stack->isEmpty()) { | |
// nothing more to manage | |
$this->logger->debug('Stack is now empty'); | |
$session->unset(self::class); | |
} else { | |
// set the new value | |
// A test is needed for this: theoretically this only happens when | |
// there are multiple redirects set in the same flow. But I'm not | |
// certain this is even possible. | |
$this->logger->notice('Redirect stack (expected empty)', $this->toArray()); | |
$session->set(self::class, serialize($this->stack)); | |
} | |
$redirectStr = current($redirect); | |
$this->logger->debug('Redirect was popped', $redirect); | |
return $redirectStr; | |
} | |
/** | |
* Detect a valid deep link. If found, add it to the stack and | |
* remove it from the request. | |
*/ | |
private function detectDeepLink(ServerRequestInterface $request): ServerRequestInterface | |
{ | |
$params = $request->getQueryParams(); | |
$fwd = $params['fwd'] ?? null; | |
if (null === $fwd) { | |
return $request; | |
} | |
unset($params['fwd']); | |
$this->logger->info('Found fwd in GET param', ['fwd' => $fwd]); | |
$this->push('deep-link', $fwd); | |
return $request->withQueryParams($params); | |
} | |
private function getValidUri(string $uri): string | |
{ | |
try { | |
$uriObj = UriFactory::factory($uri); | |
} catch (\Throwable $t) { | |
$this->logger->error('Uri for redirect is invalid', ['invalid_uri' => $uri]); | |
throw RuntimeException::uriInvalid('We will not redirect to invalid URI', $t); | |
} | |
if (!$uriObj->isAbsolute()) { | |
$this->logger->error('Uri for redirect is not absolute', ['invalid_uri' => $uri]); | |
throw RuntimeException::uriNotAbsolute('We will not redirect to relative URI'); | |
} | |
return (string)$uriObj; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment