+ INVALID_REQUEST_REDIRECT + +
+ + + + +
+ public
+ mixed
+ INVALID_REQUEST_REDIRECT
+ = 14
+
+
+
+
+
+
From 1af270b42f832d1425c3c2bba76911374b10c96a Mon Sep 17 00:00:00 2001
From: Barnaby Walters Trust Query Params
+ INVALID_REQUEST_REDIRECT
+
+
+
+
+
+
+
+ public
+ mixed
+ INVALID_REQUEST_REDIRECT
+ = 14
+
+
+
+
+
+
@@ -705,7 +739,7 @@
@@ -739,7 +773,7 @@
@@ -795,7 +829,7 @@
@@ -827,7 +861,7 @@
@@ -859,7 +893,7 @@
@@ -891,7 +925,7 @@
@@ -923,7 +957,7 @@
+ protected
+ bool
+ $requirePkce
+
+
+
+
+
Constructor
@@ -912,7 +949,7 @@ as the logger for any objects passed in config which implement
Server.php
:
- 286
+ 291
@@ -944,7 +981,7 @@ as the logger for any objects passed in config which implement
Server.php
:
- 325
+ 330
Handle Authorization Endpoint Request
@@ -1019,7 +1056,7 @@ error behaviour, one way to do so is to subclass Serve
Handle Token Endpoint Request
@@ -1075,7 +1112,7 @@ error behaviour, one way to do so is to subclass Serve
Handle Exception
diff --git a/docs/coverage/Callback/AuthorizationFormInterface.php.html b/docs/coverage/Callback/AuthorizationFormInterface.php.html
index 2e105aa..73741d1 100644
--- a/docs/coverage/Callback/AuthorizationFormInterface.php.html
+++ b/docs/coverage/Callback/AuthorizationFormInterface.php.html
@@ -163,7 +163,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Callback/DefaultAuthorizationForm.php.html b/docs/coverage/Callback/DefaultAuthorizationForm.php.html
index d6d9e6c..80bb2e6 100644
--- a/docs/coverage/Callback/DefaultAuthorizationForm.php.html
+++ b/docs/coverage/Callback/DefaultAuthorizationForm.php.html
@@ -239,10 +239,10 @@
47 * @param LoggerInterface|null $logger A logger.
48 */
49 public function __construct(?string $formTemplatePath=null, ?string $csrfKey=null, ?LoggerInterface $logger=null) {
- 50 $this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../../templates/default_authorization_page.html.php';
- 51 $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
- 52 $this->logger = $logger ?? new NullLogger;
- 53 }
+ 50 $this->formTemplatePath = $formTemplatePath ?? __DIR__ . '/../../templates/default_authorization_page.html.php';
+ 51 $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
+ 52 $this->logger = $logger ?? new NullLogger;
+ 53 }
54
55 public function showForm(ServerRequestInterface $request, array $authenticationResult, string $formAction, ?array $clientHApp): ResponseInterface {
56 // Show an authorization page. List all requested scopes, as this default
@@ -279,21 +279,21 @@
87
88 public function transformAuthorizationCode(ServerRequestInterface $request, array $code): array {
89 // Add any granted scopes from the form to the code.
- 90 $grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope[]'] ?? [];
+ 90 $grantedScopes = $request->getParsedBody()['taproot_indieauth_server_scope[]'] ?? [];
91
92 // This default implementation naievely accepts any scopes it receives from the form.
93 // You may wish to perform some sort of validation.
- 94 $code['scope'] = join(' ', $grantedScopes);
+ 94 $code['scope'] = join(' ', $grantedScopes);
95
96 // You may wish to additionally make any other necessary changes to the the code based on
97 // the form submission, e.g. if the user set a custom token lifetime, or wanted extra data
98 // stored on the token to affect how it behaves.
- 99 return $code;
+ 99 return $code;
100 }
101
102 public function setLogger(LoggerInterface $logger) {
- 103 $this->logger = $logger;
- 104 }
+ 103 $this->logger = $logger;
+ 104 }
105 }
@@ -305,7 +305,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html
index f528270..dd1074d 100644
--- a/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html
+++ b/docs/coverage/Callback/SingleUserPasswordAuthenticationCallback.php.html
@@ -209,18 +209,18 @@
59 * @param string|null $csrfKey The key under which to fetch a CSRF token from `$request` attributes, and as the CSRF token name in submitted form data. Defaults to the Server default, only change if you’re using a custom CSRF middleware.
60 */
61 public function __construct(array $user, string $hashedPassword, ?string $formTemplate=null, ?string $csrfKey=null) {
- 62 if (!isset($user['me'])) {
+ 62 if (!isset($user['me'])) {
63 throw new BadMethodCallException('The $user array MUST contain a “me” key, the value which must be the user’s canonical URL as a string.');
64 }
65
- 66 if (is_null(password_get_info($hashedPassword)['algo'])) {
+ 66 if (is_null(password_get_info($hashedPassword)['algo'])) {
67 throw new BadMethodCallException('The provided $hashedPassword was not a valid hash created by the password_hash() function.');
68 }
- 69 $this->user = $user;
- 70 $this->hashedPassword = $hashedPassword;
- 71 $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php';
- 72 $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
- 73 }
+ 69 $this->user = $user;
+ 70 $this->hashedPassword = $hashedPassword;
+ 71 $this->formTemplate = $formTemplate ?? __DIR__ . '/../../templates/single_user_password_authentication_form.html.php';
+ 72 $this->csrfKey = $csrfKey ?? \Taproot\IndieAuth\Server::DEFAULT_CSRF_KEY;
+ 73 }
74
75 public function __invoke(ServerRequestInterface $request, string $formAction, ?string $normalizedMeUrl=null) {
76 // If the request is a form submission with a matching password, return the corresponding
@@ -247,7 +247,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Callback/dashboard.html b/docs/coverage/Callback/dashboard.html
index e91206e..7191317 100644
--- a/docs/coverage/Callback/dashboard.html
+++ b/docs/coverage/Callback/dashboard.html
@@ -136,7 +136,7 @@
diff --git a/docs/coverage/Callback/index.html b/docs/coverage/Callback/index.html
index 520978f..f21d8d8 100644
--- a/docs/coverage/Callback/index.html
+++ b/docs/coverage/Callback/index.html
@@ -152,7 +152,7 @@
High: 90% to 100%
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/IndieAuthException.php.html b/docs/coverage/IndieAuthException.php.html
index 80597d9..3f4a2f2 100644
--- a/docs/coverage/IndieAuthException.php.html
+++ b/docs/coverage/IndieAuthException.php.html
@@ -100,7 +100,7 @@
- create
+ create
0.00% covered (danger)
@@ -121,7 +121,7 @@
- getStatusCode
+ getStatusCode
100.00% covered (success)
@@ -142,7 +142,7 @@
- getExplanation
+ getExplanation
0.00% covered (danger)
@@ -163,7 +163,7 @@
- getInfo
+ getInfo
100.00% covered (success)
@@ -184,7 +184,7 @@
- trustQueryParams
+ trustQueryParams
0.00% covered (danger)
@@ -205,7 +205,7 @@
- getRequest
+ getRequest
100.00% covered (success)
@@ -254,66 +254,68 @@
21 const INVALID_SCOPE = 11;
22 const INVALID_GRANT = 12;
23 const INVALID_REQUEST = 13;
- 24
- 25 const EXC_INFO = [
- 26 self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'],
- 27 self::INTERNAL_ERROR_REDIRECT => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'],
- 28 self::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'],
- 29 self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH => ['statusCode' => 302, 'name' => 'Request Missing Hash', 'error' => 'internal_error'],
- 30 self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH => ['statusCode' => 302, 'name' => 'Request Hash Invalid', 'error' => 'internal_error'],
- 31 // TODO: should this one be a 500 because it’s an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care?
- 32 self::HTTP_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Error Fetching Client App URL', 'explanation' => 'Fetching the client app (client_id) failed.'],
- 33 self::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Internal Error fetching client app URI', 'explanation' => 'Fetching the client app (client_id) failed due to an internal error.'],
- 34 self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) either was not a valid URI, did not sufficiently match client_id, or did not exactly match any redirect URIs parsed from fetching the client_id.'],
- 35 self::INVALID_CLIENT_ID => ['statusCode' => 400, 'name' => 'Invalid Client Identifier URI', 'explanation' => 'The Client Identifier was not valid.'],
- 36 self::INVALID_STATE => ['statusCode' => 302, 'name' => 'Invalid state Parameter', 'error' => 'invalid_request'],
- 37 self::INVALID_CODE_CHALLENGE => ['statusCode' => 302, 'name' => 'Invalid code_challenge Parameter', 'error' => 'invalid_request'],
- 38 self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'],
- 39 self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'],
- 40 self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'],
- 41 ];
- 42
- 43 protected ServerRequestInterface $request;
+ 24 const INVALID_REQUEST_REDIRECT = 14;
+ 25
+ 26 const EXC_INFO = [
+ 27 self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'],
+ 28 self::INTERNAL_ERROR_REDIRECT => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'],
+ 29 self::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM => ['statusCode' => 302, 'name' => 'Internal Server Error', 'error' => 'internal_error'],
+ 30 self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH => ['statusCode' => 302, 'name' => 'Request Missing Hash', 'error' => 'internal_error'],
+ 31 self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH => ['statusCode' => 302, 'name' => 'Request Hash Invalid', 'error' => 'internal_error'],
+ 32 // TODO: should this one be a 500 because it’s an internal server error, or a 400 because the client_id was likely invalid? Is anyone ever going to notice, or care?
+ 33 self::HTTP_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Error Fetching Client App URL', 'explanation' => 'Fetching the client app (client_id) failed.'],
+ 34 self::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID => ['statusCode' => 500, 'name' => 'Internal Error fetching client app URI', 'explanation' => 'Fetching the client app (client_id) failed due to an internal error.'],
+ 35 self::INVALID_REDIRECT_URI => ['statusCode' => 400, 'name' => 'Invalid Client App Redirect URI', 'explanation' => 'The client app redirect URI (redirect_uri) either was not a valid URI, did not sufficiently match client_id, or did not exactly match any redirect URIs parsed from fetching the client_id.'],
+ 36 self::INVALID_CLIENT_ID => ['statusCode' => 400, 'name' => 'Invalid Client Identifier URI', 'explanation' => 'The Client Identifier was not valid.'],
+ 37 self::INVALID_STATE => ['statusCode' => 302, 'name' => 'Invalid state Parameter', 'error' => 'invalid_request'],
+ 38 self::INVALID_CODE_CHALLENGE => ['statusCode' => 302, 'name' => 'Invalid code_challenge Parameter', 'error' => 'invalid_request'],
+ 39 self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'],
+ 40 self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'],
+ 41 self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'],
+ 42 self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'],
+ 43 ];
44
- 45 public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self {
- 46 // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code.
- 47 if (!in_array($code, array_keys(self::EXC_INFO))) {
- 48 $code = 0;
- 49 }
- 50 $message = self::EXC_INFO[$code]['name'];
- 51 $e = new self($message, $code, $previous);
- 52 $e->request = $request;
- 53 return $e;
- 54 }
- 55
- 56 public function getStatusCode() {
- 57 return $this->getInfo()['statusCode'] ?? 500;
- 58 }
- 59
- 60 public function getExplanation() {
- 61 return $this->getInfo()['explanation'] ?? 'An unknown error occured.';
- 62 }
- 63
- 64 public function getInfo() {
- 65 return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR];
- 66 }
- 67
- 68 /**
- 69 * Trust Query Params
- 70 *
- 71 * Only useful on authorization form submission requests. If this returns false,
- 72 * the client_id and/or request_uri have likely been tampered with, and the error
- 73 * page SHOULD NOT offer the user a link to them.
- 74 */
- 75 public function trustQueryParams() {
- 76 return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH
- 77 || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH;
- 78 }
- 79
- 80 public function getRequest() {
- 81 return $this->request;
- 82 }
- 83 }
+ 45 protected ServerRequestInterface $request;
+ 46
+ 47 public static function create(int $code, ServerRequestInterface $request, ?Throwable $previous=null): self {
+ 48 // Only accept known codes. Default to 0 (generic internal error) on an unrecognised code.
+ 49 if (!in_array($code, array_keys(self::EXC_INFO))) {
+ 50 $code = 0;
+ 51 }
+ 52 $message = self::EXC_INFO[$code]['name'];
+ 53 $e = new self($message, $code, $previous);
+ 54 $e->request = $request;
+ 55 return $e;
+ 56 }
+ 57
+ 58 public function getStatusCode() {
+ 59 return $this->getInfo()['statusCode'] ?? 500;
+ 60 }
+ 61
+ 62 public function getExplanation() {
+ 63 return $this->getInfo()['explanation'] ?? 'An unknown error occured.';
+ 64 }
+ 65
+ 66 public function getInfo() {
+ 67 return self::EXC_INFO[$this->code] ?? self::EXC_INFO[self::INTERNAL_ERROR];
+ 68 }
+ 69
+ 70 /**
+ 71 * Trust Query Params
+ 72 *
+ 73 * Only useful on authorization form submission requests. If this returns false,
+ 74 * the client_id and/or request_uri have likely been tampered with, and the error
+ 75 * page SHOULD NOT offer the user a link to them.
+ 76 */
+ 77 public function trustQueryParams() {
+ 78 return $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH
+ 79 || $this->code == self::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH;
+ 80 }
+ 81
+ 82 public function getRequest() {
+ 83 return $this->request;
+ 84 }
+ 85 }
@@ -324,7 +326,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Middleware/ClosureRequestHandler.php.html b/docs/coverage/Middleware/ClosureRequestHandler.php.html
index 3b9ebf5..d7afa77 100644
--- a/docs/coverage/Middleware/ClosureRequestHandler.php.html
+++ b/docs/coverage/Middleware/ClosureRequestHandler.php.html
@@ -162,12 +162,12 @@
12 protected array $args;
13
14 public function __construct(callable $callable) {
- 15 $this->callable = $callable;
- 16 $this->args = array_slice(func_get_args(), 1);
- 17 }
+ 15 $this->callable = $callable;
+ 16 $this->args = array_slice(func_get_args(), 1);
+ 17 }
18
19 public function handle(ServerRequestInterface $request): ResponseInterface {
- 20 return call_user_func_array($this->callable, array_merge([$request], $this->args));
+ 20 return call_user_func_array($this->callable, array_merge([$request], $this->args));
21 }
22 }
@@ -180,7 +180,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html
index b449dba..7e166aa 100644
--- a/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html
+++ b/docs/coverage/Middleware/DoubleSubmitCookieCsrfMiddleware.php.html
@@ -255,60 +255,60 @@
63 * the return value of which will be returned as-is.
64 */
65 public function __construct(?string $attribute=self::ATTRIBUTE, ?int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH, $logger=null) {
- 66 $this->attribute = $attribute ?? self::ATTRIBUTE;
- 67 $this->ttl = $ttl ?? self::TTL;
- 68 $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH;
+ 66 $this->attribute = $attribute ?? self::ATTRIBUTE;
+ 67 $this->ttl = $ttl ?? self::TTL;
+ 68 $this->tokenLength = $tokenLength ?? self::CSRF_TOKEN_LENGTH;
69
- 70 if (!is_callable($errorResponse)) {
- 71 if (!$errorResponse instanceof ResponseInterface) {
- 72 if (!is_string($errorResponse)) {
+ 70 if (!is_callable($errorResponse)) {
+ 71 if (!$errorResponse instanceof ResponseInterface) {
+ 72 if (!is_string($errorResponse)) {
73 $errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING;
74 }
- 75 $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse);
+ 75 $errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse);
76 }
- 77 $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; };
+ 77 $errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; };
78 }
- 79 $this->errorResponse = $errorResponse;
+ 79 $this->errorResponse = $errorResponse;
80
- 81 if (!$logger instanceof LoggerInterface) {
- 82 $logger = new NullLogger();
+ 81 if (!$logger instanceof LoggerInterface) {
+ 82 $logger = new NullLogger();
83 }
- 84 $this->logger = $logger;
- 85 }
+ 84 $this->logger = $logger;
+ 85 }
86
87 public function setLogger(LoggerInterface $logger) {
- 88 $this->logger = $logger;
- 89 }
+ 88 $this->logger = $logger;
+ 89 }
90
91 public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
92 // Generate a new CSRF token, add it to the request attributes, and as a cookie on the response.
- 93 $csrfToken = generateRandomPrintableAsciiString($this->tokenLength);
- 94 $request = $request->withAttribute($this->attribute, $csrfToken);
+ 93 $csrfToken = generateRandomPrintableAsciiString($this->tokenLength);
+ 94 $request = $request->withAttribute($this->attribute, $csrfToken);
95
- 96 if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) {
+ 96 if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS) && !$this->isValid($request)) {
97 // This request is a write method with invalid CSRF parameters.
98 $response = call_user_func($this->errorResponse, $request);
99 } else {
- 100 $response = $handler->handle($request);
+ 100 $response = $handler->handle($request);
101 }
102
103 // Add the new CSRF cookie, restricting its scope to match the current request.
- 104 $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute)
- 105 ->withValue($csrfToken)
- 106 ->withMaxAge($this->ttl)
- 107 ->withSecure($request->getUri()->getScheme() == 'https')
- 108 ->withDomain($request->getUri()->getHost())
- 109 ->withPath($request->getUri()->getPath()));
+ 104 $response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute)
+ 105 ->withValue($csrfToken)
+ 106 ->withMaxAge($this->ttl)
+ 107 ->withSecure($request->getUri()->getScheme() == 'https')
+ 108 ->withDomain($request->getUri()->getHost())
+ 109 ->withPath($request->getUri()->getPath()));
110
- 111 return $response;
+ 111 return $response;
112 }
113
114 protected function isValid(ServerRequestInterface $request) {
- 115 if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) {
- 116 if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) {
+ 115 if (array_key_exists($this->attribute, $request->getParsedBody() ?? [])) {
+ 116 if (array_key_exists($this->attribute, $request->getCookieParams() ?? [])) {
117 // TODO: make sure CSRF token isn’t the empty string, possibly also check that it’s the same length
118 // as defined in $this->tokenLength.
- 119 return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]);
+ 119 return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]);
120 }
121 }
122 return false;
@@ -324,7 +324,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Middleware/NoOpMiddleware.php.html b/docs/coverage/Middleware/NoOpMiddleware.php.html
index 9c5a72a..3980b91 100644
--- a/docs/coverage/Middleware/NoOpMiddleware.php.html
+++ b/docs/coverage/Middleware/NoOpMiddleware.php.html
@@ -157,7 +157,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Middleware/ResponseRequestHandler.php.html b/docs/coverage/Middleware/ResponseRequestHandler.php.html
index 1958da1..23a68c6 100644
--- a/docs/coverage/Middleware/ResponseRequestHandler.php.html
+++ b/docs/coverage/Middleware/ResponseRequestHandler.php.html
@@ -177,7 +177,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Middleware/dashboard.html b/docs/coverage/Middleware/dashboard.html
index a5e9a1c..7dbbe67 100644
--- a/docs/coverage/Middleware/dashboard.html
+++ b/docs/coverage/Middleware/dashboard.html
@@ -138,7 +138,7 @@
diff --git a/docs/coverage/Middleware/index.html b/docs/coverage/Middleware/index.html
index 32c5786..094e44d 100644
--- a/docs/coverage/Middleware/index.html
+++ b/docs/coverage/Middleware/index.html
@@ -195,7 +195,7 @@
High: 90% to 100%
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Server.php.html b/docs/coverage/Server.php.html
index b86bab1..3306f10 100644
--- a/docs/coverage/Server.php.html
+++ b/docs/coverage/Server.php.html
@@ -52,22 +52,22 @@
0.00%
0 / 1
-
- 60.00% covered (warning)
+
+ 80.00% covered (warning)
- 60.00%
- 3 / 5
+ 80.00%
+ 4 / 5
CRAP
-
- 94.98% covered (success)
+
+ 96.69% covered (success)
- 94.98%
- 246 / 259
+ 96.69%
+ 263 / 272
@@ -81,26 +81,26 @@
0.00%
0 / 1
-
- 60.00% covered (warning)
+
+ 80.00% covered (warning)
- 60.00%
- 3 / 5
- 91.02
+ 80.00%
+ 4 / 5
+ 105
-
- 94.98% covered (success)
+
+ 96.69% covered (success)
- 94.98%
- 246 / 259
+ 96.69%
+ 263 / 272
- __construct
+ __construct
100.00% covered (success)
@@ -117,11 +117,11 @@
100.00%
- 45 / 45
+ 46 / 46
- getTokenStorage
+ getTokenStorage
100.00% covered (success)
@@ -142,7 +142,7 @@
- handleAuthorizationEndpointRequest
+ handleAuthorizationEndpointRequest
0.00% covered (danger)
@@ -151,40 +151,40 @@
0.00%
0 / 1
- 59.11
+ 67.70
-
- 93.08% covered (success)
+
+ 94.61% covered (success)
- 93.08%
- 148 / 159
+ 94.61%
+ 158 / 167
- handleTokenEndpointRequest
-
-
- 0.00% covered (danger)
-
-
-
- 0.00%
- 0 / 1
- 11
+ handleTokenEndpointRequest
-
- 95.35% covered (success)
+
+ 100.00% covered (success)
- 95.35%
- 41 / 43
+ 100.00%
+ 1 / 1
+ 17
+
+
+ 100.00% covered (success)
+
+
+
+ 100.00%
+ 47 / 47
- handleException
+ handleException
100.00% covered (success)
@@ -333,694 +333,725 @@
121
122 protected string $secret;
123
- 124 /**
- 125 * Constructor
- 126 *
- 127 * Server instances are configured by passing a config array to the constructor.
+ 124 protected bool $requirePkce;
+ 125
+ 126 /**
+ 127 * Constructor
128 *
- 129 * The following keys are required:
+ 129 * Server instances are configured by passing a config array to the constructor.
130 *
- 131 * * `authenticationHandler`: a callable with the signature
- 132 * `function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl): array|ResponseInterface`.
- 133 * This function is called on IndieAuth authorization requests, after validating the query parameters.
- 134 *
- 135 * It should check to see if $request is authenticated, then:
- 136 * * If it is authenticated, return an array which MUST have a `me` key, mapping to the
- 137 * canonical URL of the currently logged-in user. It may additionally have a `profile` key. These
- 138 * keys will be stored in the authorization code and sent to the client, if successful.
- 139 * * If it is not authenticated, either present or redirect to an authentication flow. This flow MUST
- 140 * redirect the logged-in used back to `$authenticationRedirect`.
- 141 *
- 142 * If the request has a valid `me` parameter, the canonicalized version of it is passed as
- 143 * `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used
- 144 * as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT
- 145 * be considered valid data.
- 146 *
- 147 * If redirecting to an existing authentication flow, this callable can usually be implemented as a
- 148 * closure. The callable may also implement its own authentication logic. For an example, see
- 149 * `Callback\SingleUserPasswordAuthenticationCallback`.
- 150 * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used
- 151 * to hash and subsequently verify request query parameters which get passed around.
- 152 * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path,
- 153 * which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization
- 154 * codes and access tokens, as well as implementation-specific parts of the exchange process which are
- 155 * out of the scope of the Server class (e.g. lifetimes and expiry). Refer to the `Storage\TokenStorageInterface`
- 156 * documentation for more details.
- 157 *
- 158 * The following keys may be required depending on which packages you have installed:
+ 131 * The following keys are required:
+ 132 *
+ 133 * * `authenticationHandler`: a callable with the signature
+ 134 * `function (ServerRequestInterface $request, string $authenticationRedirect, ?string $normalizedMeUrl): array|ResponseInterface`.
+ 135 * This function is called on IndieAuth authorization requests, after validating the query parameters.
+ 136 *
+ 137 * It should check to see if $request is authenticated, then:
+ 138 * * If it is authenticated, return an array which MUST have a `me` key, mapping to the
+ 139 * canonical URL of the currently logged-in user. It may additionally have a `profile` key. These
+ 140 * keys will be stored in the authorization code and sent to the client, if successful.
+ 141 * * If it is not authenticated, either present or redirect to an authentication flow. This flow MUST
+ 142 * redirect the logged-in used back to `$authenticationRedirect`.
+ 143 *
+ 144 * If the request has a valid `me` parameter, the canonicalized version of it is passed as
+ 145 * `$normalizedMeUrl`. Otherwise, this parameter is null. This parameter can optionally be used
+ 146 * as a suggestion for which user to log in as in a multi-user authentication flow, but should NOT
+ 147 * be considered valid data.
+ 148 *
+ 149 * If redirecting to an existing authentication flow, this callable can usually be implemented as a
+ 150 * closure. The callable may also implement its own authentication logic. For an example, see
+ 151 * `Callback\SingleUserPasswordAuthenticationCallback`.
+ 152 * * `secret`: A cryptographically random string with a minimum length of 64 characters. Used
+ 153 * to hash and subsequently verify request query parameters which get passed around.
+ 154 * * `tokenStorage`: Either an object implementing `Storage\TokenStorageInterface`, or a string path,
+ 155 * which will be passed to `Storage\FilesystemJsonStorage`. This object handles persisting authorization
+ 156 * codes and access tokens, as well as implementation-specific parts of the exchange process which are
+ 157 * out of the scope of the Server class (e.g. lifetimes and expiry). Refer to the `Storage\TokenStorageInterface`
+ 158 * documentation for more details.
159 *
- 160 * * `httpGetWithEffectiveUrl`: must be a callable with the following signature:
- 161 * `function (string $url): array [ResponseInterface $response, string $effectiveUrl]`, where
- 162 * `$effectiveUrl` is the final URL after following any redirects (unfortunately, neither the PSR-7
- 163 * Response nor the PSR-18 Client interfaces offer a standard way of getting this very important
- 164 * data, hence the unusual return signature). If `guzzlehttp/guzzle` is installed, this parameter
- 165 * will be created automatically. Otherwise, the user must provide their own callable.
- 166 *
- 167 * The following keys are optional:
+ 160 * The following keys may be required depending on which packages you have installed:
+ 161 *
+ 162 * * `httpGetWithEffectiveUrl`: must be a callable with the following signature:
+ 163 * `function (string $url): array [ResponseInterface $response, string $effectiveUrl]`, where
+ 164 * `$effectiveUrl` is the final URL after following any redirects (unfortunately, neither the PSR-7
+ 165 * Response nor the PSR-18 Client interfaces offer a standard way of getting this very important
+ 166 * data, hence the unusual return signature). If `guzzlehttp/guzzle` is installed, this parameter
+ 167 * will be created automatically. Otherwise, the user must provide their own callable.
168 *
- 169 * * `authorizationForm`: an instance of `AuthorizationFormInterface`. Defaults to `DefaultAuthorizationForm`.
- 170 * Refer to that implementation if you wish to replace the consent screen/scope choosing/authorization form.
- 171 * * `csrfMiddleware`: an instance of `MiddlewareInterface`, which will be used to CSRF-protect the
- 172 * user-facing authorization flow. By default an instance of `DoubleSubmitCookieCsrfMiddleware`.
- 173 * Refer to that implementation if you want to replace it with your own middleware — you will
- 174 * likely have to either make sure your middleware sets the same request attribute, or alter your
- 175 * templates accordingly.
- 176 * * `exceptionTemplatePath`: string, path to a template which will be used for displaying user-facing
- 177 * errors. Defaults to `../templates/default_exception_response.html.php`, refer to that if you wish
- 178 * to write your own template.
- 179 * * `handleNonIndieAuthRequestCallback`: A callback with the following signature:
- 180 * `function (ServerRequestInterface $request): ?ResponseInterface` which will be called if the
- 181 * authorization endpoint gets a request which is not identified as an IndieAuth request or authorization
- 182 * form submission request. You could use this to handle various requests e.g. client-side requests
- 183 * made by your authentication or authorization pages, if it’s not convenient to put them elsewhere.
- 184 * Returning `null` will result in a standard `invalid_request` error being returned.
- 185 * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set
- 186 * as the logger for any objects passed in config which implement `LoggerAwareInterface`.
- 187 *
- 188 * @param array $config An array of configuration variables
- 189 * @return self
- 190 */
- 191 public function __construct(array $config) {
- 192 $config = array_merge([
- 193 'csrfMiddleware' => new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY),
- 194 'logger' => null,
- 195 self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
- 196 'tokenStorage' => null,
- 197 'httpGetWithEffectiveUrl' => null,
- 198 'authorizationForm' => new DefaultAuthorizationForm(),
- 199 'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php',
- 200 ], $config);
- 201
- 202 if (!is_string($config['exceptionTemplatePath'])) {
- 203 throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path).");
- 204 }
- 205 $this->exceptionTemplatePath = $config['exceptionTemplatePath'];
+ 169 * The following keys are optional:
+ 170 *
+ 171 * * `authorizationForm`: an instance of `AuthorizationFormInterface`. Defaults to `DefaultAuthorizationForm`.
+ 172 * Refer to that implementation if you wish to replace the consent screen/scope choosing/authorization form.
+ 173 * * `csrfMiddleware`: an instance of `MiddlewareInterface`, which will be used to CSRF-protect the
+ 174 * user-facing authorization flow. By default an instance of `DoubleSubmitCookieCsrfMiddleware`.
+ 175 * Refer to that implementation if you want to replace it with your own middleware — you will
+ 176 * likely have to either make sure your middleware sets the same request attribute, or alter your
+ 177 * templates accordingly.
+ 178 * * `exceptionTemplatePath`: string, path to a template which will be used for displaying user-facing
+ 179 * errors. Defaults to `../templates/default_exception_response.html.php`, refer to that if you wish
+ 180 * to write your own template.
+ 181 * * `handleNonIndieAuthRequestCallback`: A callback with the following signature:
+ 182 * `function (ServerRequestInterface $request): ?ResponseInterface` which will be called if the
+ 183 * authorization endpoint gets a request which is not identified as an IndieAuth request or authorization
+ 184 * form submission request. You could use this to handle various requests e.g. client-side requests
+ 185 * made by your authentication or authorization pages, if it’s not convenient to put them elsewhere.
+ 186 * Returning `null` will result in a standard `invalid_request` error being returned.
+ 187 * * `logger`: An instance of `LoggerInterface`. Will be used for internal logging, and will also be set
+ 188 * as the logger for any objects passed in config which implement `LoggerAwareInterface`.
+ 189 *
+ 190 * @param array $config An array of configuration variables
+ 191 * @return self
+ 192 */
+ 193 public function __construct(array $config) {
+ 194 $config = array_merge([
+ 195 'csrfMiddleware' => new Middleware\DoubleSubmitCookieCsrfMiddleware(self::DEFAULT_CSRF_KEY),
+ 196 'logger' => null,
+ 197 self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
+ 198 'tokenStorage' => null,
+ 199 'httpGetWithEffectiveUrl' => null,
+ 200 'authorizationForm' => new DefaultAuthorizationForm(),
+ 201 'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php',
+ 202 'requirePKCE' => true,
+ 203 ], $config);
+ 204
+ 205 $this->requirePkce = $config['requirePKCE'];
206
- 207 $secret = $config['secret'] ?? '';
- 208 if (!is_string($secret) || strlen($secret) < 64) {
- 209 throw new BadMethodCallException("\$config['secret'] must be a string with a minimum length of 64 characters.");
- 210 }
- 211 $this->secret = $secret;
- 212
- 213 if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) {
- 214 throw new BadMethodCallException("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null.");
+ 207 if (!is_string($config['exceptionTemplatePath'])) {
+ 208 throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path).");
+ 209 }
+ 210 $this->exceptionTemplatePath = $config['exceptionTemplatePath'];
+ 211
+ 212 $secret = $config['secret'] ?? '';
+ 213 if (!is_string($secret) || strlen($secret) < 64) {
+ 214 throw new BadMethodCallException("\$config['secret'] must be a string with a minimum length of 64 characters.");
215 }
- 216 $this->logger = $config['logger'] ?? new NullLogger();
+ 216 $this->secret = $secret;
217
- 218 if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) {
- 219 throw new BadMethodCallException('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.');
+ 218 if (!is_null($config['logger']) && !$config['logger'] instanceof LoggerInterface) {
+ 219 throw new BadMethodCallException("\$config['logger'] must be an instance of \\Psr\\Log\\LoggerInterface or null.");
220 }
- 221 $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST];
- 222
- 223 if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) {
- 224 throw new BadMethodCallException("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable");
+ 221 $this->logger = $config['logger'] ?? new NullLogger();
+ 222
+ 223 if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $config) and is_callable($config[self::HANDLE_AUTHENTICATION_REQUEST]))) {
+ 224 throw new BadMethodCallException('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.');
225 }
- 226 $this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST];
- 227
- 228 $tokenStorage = $config['tokenStorage'];
- 229 if (!$tokenStorage instanceof Storage\TokenStorageInterface) {
- 230 if (is_string($tokenStorage)) {
- 231 // Create a default access token storage with a TTL of 7 days.
- 232 $tokenStorage = new Storage\FilesystemJsonStorage($tokenStorage, $this->secret);
- 233 } else {
- 234 throw new BadMethodCallException("\$config['tokenStorage'] parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.");
- 235 }
- 236 }
- 237 trySetLogger($tokenStorage, $this->logger);
- 238 $this->tokenStorage = $tokenStorage;
- 239
- 240 $csrfMiddleware = $config['csrfMiddleware'];
- 241 if (!$csrfMiddleware instanceof MiddlewareInterface) {
- 242 throw new BadMethodCallException("\$config['csrfMiddleware'] must be null or implement MiddlewareInterface.");
- 243 }
- 244 trySetLogger($csrfMiddleware, $this->logger);
- 245 $this->csrfMiddleware = $csrfMiddleware;
- 246
- 247 $httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl'];
- 248 if (is_null($httpGetWithEffectiveUrl)) {
- 249 if (class_exists('\GuzzleHttp\Client')) {
- 250 $httpGetWithEffectiveUrl = function (string $uri): array {
- 251 // This code can’t be tested, ignore it for coverage purposes.
- 252 // @codeCoverageIgnoreStart
- 253 $resp = (new \GuzzleHttp\Client([
- 254 \GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => [
- 255 'max' => 10,
- 256 'strict' => true,
- 257 'referer' => true,
- 258 'track_redirects' => true
- 259 ]
- 260 ]))->get($uri);
- 261
- 262 $rdh = $resp->getHeader('X-Guzzle-Redirect-History');
- 263 $effectiveUrl = empty($rdh) ? $uri : array_values($rdh)[count($rdh) - 1];
- 264
- 265 return [$resp, $effectiveUrl];
- 266 };
- 267 } else {
- 268 throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] was not provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable.");
- 269 // @codeCoverageIgnoreEnd
- 270 }
- 271 } else {
- 272 if (!is_callable($httpGetWithEffectiveUrl)) {
- 273 throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] must be callable.");
- 274 }
- 275 }
- 276 trySetLogger($httpGetWithEffectiveUrl, $this->logger);
- 277 $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl;
- 278
- 279 if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) {
- 280 throw new BadMethodCallException("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm.");
- 281 }
- 282 $this->authorizationForm = $config['authorizationForm'];
- 283 trySetLogger($this->authorizationForm, $this->logger);
- 284 }
- 285
- 286 public function getTokenStorage(): TokenStorageInterface {
- 287 return $this->tokenStorage;
- 288 }
- 289
- 290 /**
- 291 * Handle Authorization Endpoint Request
- 292 *
- 293 * This method handles all requests to your authorization endpoint, passing execution off to
- 294 * other callbacks when necessary. The logical flow can be summarised as follows:
- 295 *
- 296 * * If this request an **auth code exchange for profile information**, validate the request
- 297 * and return a response or error response.
- 298 * * Otherwise, proceed, wrapping all execution in CSRF-protection middleware.
- 299 * * Validate the request’s indieauth authorization code request parameters, returning an
- 300 * error response if any are missing or invalid.
- 301 * * Call the authentication callback
- 302 * * If the callback returned an instance of ResponseInterface, the user is not currently
- 303 * logged in. Return the Response, which will presumably start an authentication flow.
- 304 * * Otherwise, the callback returned information about the currently logged-in user. Continue.
- 305 * * If this request is an authorization form submission, validate the data, store and authorization
- 306 * code and return a redirect response to the client redirect_uri with code data. On an error, return
- 307 * an appropriate error response.
- 308 * * Otherwise, fetch the client_id, parse app data if present, validate the `redirect_uri` and present
- 309 * the authorization form/consent screen to the user.
- 310 * * If none of the above apply, try calling the non-indieauth request handler. If it returns a Response,
- 311 * return that, otherwise return an error response.
- 312 *
- 313 * This route should NOT be wrapped in additional CSRF-protection, due to the need to handle API
- 314 * POST requests from the client. Make sure you call it from a route which is excluded from any
- 315 * CSRF-protection you might be using. To customise the CSRF protection used internally, refer to the
- 316 * `__construct` config array documentation for the `csrfMiddleware` key.
+ 226 $this->handleAuthenticationRequestCallback = $config[self::HANDLE_AUTHENTICATION_REQUEST];
+ 227
+ 228 if (!is_callable($config[self::HANDLE_NON_INDIEAUTH_REQUEST])) {
+ 229 throw new BadMethodCallException("\$config['" . self::HANDLE_NON_INDIEAUTH_REQUEST . "'] must be callable");
+ 230 }
+ 231 $this->handleNonIndieAuthRequest = $config[self::HANDLE_NON_INDIEAUTH_REQUEST];
+ 232
+ 233 $tokenStorage = $config['tokenStorage'];
+ 234 if (!$tokenStorage instanceof Storage\TokenStorageInterface) {
+ 235 if (is_string($tokenStorage)) {
+ 236 // Create a default access token storage with a TTL of 7 days.
+ 237 $tokenStorage = new Storage\FilesystemJsonStorage($tokenStorage, $this->secret);
+ 238 } else {
+ 239 throw new BadMethodCallException("\$config['tokenStorage'] parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.");
+ 240 }
+ 241 }
+ 242 trySetLogger($tokenStorage, $this->logger);
+ 243 $this->tokenStorage = $tokenStorage;
+ 244
+ 245 $csrfMiddleware = $config['csrfMiddleware'];
+ 246 if (!$csrfMiddleware instanceof MiddlewareInterface) {
+ 247 throw new BadMethodCallException("\$config['csrfMiddleware'] must be null or implement MiddlewareInterface.");
+ 248 }
+ 249 trySetLogger($csrfMiddleware, $this->logger);
+ 250 $this->csrfMiddleware = $csrfMiddleware;
+ 251
+ 252 $httpGetWithEffectiveUrl = $config['httpGetWithEffectiveUrl'];
+ 253 if (is_null($httpGetWithEffectiveUrl)) {
+ 254 if (class_exists('\GuzzleHttp\Client')) {
+ 255 $httpGetWithEffectiveUrl = function (string $uri): array {
+ 256 // This code can’t be tested, ignore it for coverage purposes.
+ 257 // @codeCoverageIgnoreStart
+ 258 $resp = (new \GuzzleHttp\Client([
+ 259 \GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => [
+ 260 'max' => 10,
+ 261 'strict' => true,
+ 262 'referer' => true,
+ 263 'track_redirects' => true
+ 264 ]
+ 265 ]))->get($uri);
+ 266
+ 267 $rdh = $resp->getHeader('X-Guzzle-Redirect-History');
+ 268 $effectiveUrl = empty($rdh) ? $uri : array_values($rdh)[count($rdh) - 1];
+ 269
+ 270 return [$resp, $effectiveUrl];
+ 271 };
+ 272 } else {
+ 273 throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] was not provided, and guzzlehttp/guzzle was not installed. Either require guzzlehttp/guzzle, or provide a valid callable.");
+ 274 // @codeCoverageIgnoreEnd
+ 275 }
+ 276 } else {
+ 277 if (!is_callable($httpGetWithEffectiveUrl)) {
+ 278 throw new BadMethodCallException("\$config['httpGetWithEffectiveUrl'] must be callable.");
+ 279 }
+ 280 }
+ 281 trySetLogger($httpGetWithEffectiveUrl, $this->logger);
+ 282 $this->httpGetWithEffectiveUrl = $httpGetWithEffectiveUrl;
+ 283
+ 284 if (!$config['authorizationForm'] instanceof AuthorizationFormInterface) {
+ 285 throw new BadMethodCallException("When provided, \$config['authorizationForm'] must implement Taproot\IndieAuth\Callback\AuthorizationForm.");
+ 286 }
+ 287 $this->authorizationForm = $config['authorizationForm'];
+ 288 trySetLogger($this->authorizationForm, $this->logger);
+ 289 }
+ 290
+ 291 public function getTokenStorage(): TokenStorageInterface {
+ 292 return $this->tokenStorage;
+ 293 }
+ 294
+ 295 /**
+ 296 * Handle Authorization Endpoint Request
+ 297 *
+ 298 * This method handles all requests to your authorization endpoint, passing execution off to
+ 299 * other callbacks when necessary. The logical flow can be summarised as follows:
+ 300 *
+ 301 * * If this request an **auth code exchange for profile information**, validate the request
+ 302 * and return a response or error response.
+ 303 * * Otherwise, proceed, wrapping all execution in CSRF-protection middleware.
+ 304 * * Validate the request’s indieauth authorization code request parameters, returning an
+ 305 * error response if any are missing or invalid.
+ 306 * * Call the authentication callback
+ 307 * * If the callback returned an instance of ResponseInterface, the user is not currently
+ 308 * logged in. Return the Response, which will presumably start an authentication flow.
+ 309 * * Otherwise, the callback returned information about the currently logged-in user. Continue.
+ 310 * * If this request is an authorization form submission, validate the data, store and authorization
+ 311 * code and return a redirect response to the client redirect_uri with code data. On an error, return
+ 312 * an appropriate error response.
+ 313 * * Otherwise, fetch the client_id, parse app data if present, validate the `redirect_uri` and present
+ 314 * the authorization form/consent screen to the user.
+ 315 * * If none of the above apply, try calling the non-indieauth request handler. If it returns a Response,
+ 316 * return that, otherwise return an error response.
317 *
- 318 * Most user-facing errors are thrown as instances of `IndieAuthException`, which are passed off to
- 319 * `handleException` to be turned into an instance of `ResponseInterface`. If you want to customise
- 320 * error behaviour, one way to do so is to subclass `Server` and override that method.
- 321 *
- 322 * @param ServerRequestInterface $request
- 323 * @return ResponseInterface
- 324 */
- 325 public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
- 326 $this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
- 327
- 328 // If it’s a profile information request:
- 329 if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
- 330 $this->logger->info('Handling a request to redeem an authorization code for profile information.');
- 331
- 332 $bodyParams = $request->getParsedBody();
- 333
- 334 if (!isset($bodyParams['code'])) {
- 335 $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.');
- 336 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 337 'error' => 'invalid_request',
- 338 'error_description' => 'The code parameter was missing.'
- 339 ]));
- 340 }
- 341
- 342 // Attempt to internally exchange the provided auth code for an access token.
- 343 // We do this before anything else so that the auth code is invalidated as soon as the request starts,
- 344 // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler
- 345 // and more flexible interface for TokenStorage implementors.
- 346 try {
- 347 // Call the token exchange method, passing in a callback which performs additional validation
- 348 // on the auth code before it gets exchanged.
- 349 $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
- 350 // Verify that all required parameters are included.
- 351 $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier'];
- 352 $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
- 353 return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
- 354 });
- 355 if (!empty($missingRequiredParameters)) {
- 356 $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
- 357 throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request);
- 358 }
- 359
- 360 // Verify that it was issued for the same client_id and redirect_uri
- 361 if ($authCode['client_id'] !== $bodyParams['client_id']
- 362 || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) {
- 363 $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token.");
- 364 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
- 365 }
- 366
- 367 // Check that the supplied code_verifier hashes to the stored code_challenge
- 368 // TODO: support method = plain as well as S256.
- 369 if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
- 370 $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
- 371 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
- 372 }
- 373
- 374 // Check that this token either grants at most the profile scope.
- 375 $requestedScopes = array_filter(explode(' ', $authCode['scope'] ?? ''));
- 376 if (!empty($requestedScopes) && $requestedScopes != ['profile']) {
- 377 $this->logger->error("An exchange request for a token granting scopes other than “profile” was sent to the authorization endpoint.");
- 378 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
- 379 }
- 380 });
- 381 } catch (IndieAuthException $e) {
- 382 // If an exception was thrown, return a corresponding error response.
- 383 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 384 'error' => $e->getInfo()['error'],
- 385 'error_description' => $e->getMessage()
- 386 ]));
- 387 }
- 388
- 389 if (is_null($tokenData)) {
- 390 $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams);
- 391 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 392 'error' => 'invalid_grant',
- 393 'error_description' => 'The provided credentials were not valid.'
- 394 ]));
- 395 }
- 396
- 397 // TODO: return an error if the token doesn’t contain a me key.
- 398
- 399 // If everything checked out, return {"me": "https://example.com"} response
- 400 return new Response(200, [
- 401 'content-type' => 'application/json',
- 402 'cache-control' => 'no-store',
- 403 ], json_encode(array_filter($tokenData, function (string $k) {
- 404 // Prevent codes exchanged at the authorization endpoint from returning any information other than
- 405 // me and profile.
- 406 return in_array($k, ['me', 'profile']);
- 407 }, ARRAY_FILTER_USE_KEY)));
- 408 }
- 409
- 410 // Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
- 411 // closure gymnastics here to selectively-CSRF-protect requests which do need it.
- 412 return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) {
- 413 // Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it
- 414 // to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a
- 415 // response.
- 416 try {
- 417 $queryParams = $request->getQueryParams();
- 418
- 419 /** @var ResponseInterface|null $clientIdResponse */
- 420 /** @var string|null $clientIdEffectiveUrl */
- 421 /** @var array|null $clientIdMf2 */
- 422 list($clientIdResponse, $clientIdEffectiveUrl, $clientIdMf2) = [null, null, null];
+ 318 * This route should NOT be wrapped in additional CSRF-protection, due to the need to handle API
+ 319 * POST requests from the client. Make sure you call it from a route which is excluded from any
+ 320 * CSRF-protection you might be using. To customise the CSRF protection used internally, refer to the
+ 321 * `__construct` config array documentation for the `csrfMiddleware` key.
+ 322 *
+ 323 * Most user-facing errors are thrown as instances of `IndieAuthException`, which are passed off to
+ 324 * `handleException` to be turned into an instance of `ResponseInterface`. If you want to customise
+ 325 * error behaviour, one way to do so is to subclass `Server` and override that method.
+ 326 *
+ 327 * @param ServerRequestInterface $request
+ 328 * @return ResponseInterface
+ 329 */
+ 330 public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
+ 331 $this->logger->info('Handling an IndieAuth Authorization Endpoint request.');
+ 332
+ 333 // If it’s a profile information request:
+ 334 if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
+ 335 $this->logger->info('Handling a request to redeem an authorization code for profile information.');
+ 336
+ 337 $bodyParams = $request->getParsedBody();
+ 338
+ 339 if (!isset($bodyParams['code'])) {
+ 340 $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.');
+ 341 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 342 'error' => 'invalid_request',
+ 343 'error_description' => 'The code parameter was missing.'
+ 344 ]));
+ 345 }
+ 346
+ 347 // Attempt to internally exchange the provided auth code for an access token.
+ 348 // We do this before anything else so that the auth code is invalidated as soon as the request starts,
+ 349 // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler
+ 350 // and more flexible interface for TokenStorage implementors.
+ 351 try {
+ 352 // Call the token exchange method, passing in a callback which performs additional validation
+ 353 // on the auth code before it gets exchanged.
+ 354 $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
+ 355 // Verify that all required parameters are included.
+ 356 $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri'];
+ 357 $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
+ 358 return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
+ 359 });
+ 360 if (!empty($missingRequiredParameters)) {
+ 361 $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
+ 362 throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request);
+ 363 }
+ 364
+ 365 // Verify that it was issued for the same client_id and redirect_uri
+ 366 if ($authCode['client_id'] !== $bodyParams['client_id']
+ 367 || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) {
+ 368 $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token.");
+ 369 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 370 }
+ 371
+ 372 // If the auth code was requested with no code_challenge, but the exchange request provides a
+ 373 // code_verifier, return an error.
+ 374 if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) {
+ 375 $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge.");
+ 376 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 377 }
+ 378
+ 379 if ($this->requirePkce or !empty($authCode['code_challenge'])) {
+ 380 // Check that the supplied code_verifier hashes to the stored code_challenge
+ 381 // TODO: support method = plain as well as S256.
+ 382 if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
+ 383 $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
+ 384 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 385 }
+ 386 }
+ 387
+ 388 // Check that this token either grants at most the profile scope.
+ 389 $requestedScopes = array_filter(explode(' ', $authCode['scope'] ?? ''));
+ 390 if (!empty($requestedScopes) && $requestedScopes != ['profile']) {
+ 391 $this->logger->error("An exchange request for a token granting scopes other than “profile” was sent to the authorization endpoint.");
+ 392 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 393 }
+ 394 });
+ 395 } catch (IndieAuthException $e) {
+ 396 // If an exception was thrown, return a corresponding error response.
+ 397 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 398 'error' => $e->getInfo()['error'],
+ 399 'error_description' => $e->getMessage()
+ 400 ]));
+ 401 }
+ 402
+ 403 if (is_null($tokenData)) {
+ 404 $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams);
+ 405 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 406 'error' => 'invalid_grant',
+ 407 'error_description' => 'The provided credentials were not valid.'
+ 408 ]));
+ 409 }
+ 410
+ 411 // TODO: return an error if the token doesn’t contain a me key.
+ 412
+ 413 // If everything checked out, return {"me": "https://example.com"} response
+ 414 return new Response(200, [
+ 415 'content-type' => 'application/json',
+ 416 'cache-control' => 'no-store',
+ 417 ], json_encode(array_filter($tokenData, function (string $k) {
+ 418 // Prevent codes exchanged at the authorization endpoint from returning any information other than
+ 419 // me and profile.
+ 420 return in_array($k, ['me', 'profile']);
+ 421 }, ARRAY_FILTER_USE_KEY)));
+ 422 }
423
- 424 // If this is an authorization or approval request (allowing POST requests as well to accommodate
- 425 // approval requests and custom auth form submission.
- 426 if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
- 427 $this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]);
- 428
- 429 // Validate the Client ID.
- 430 if (!isset($queryParams['client_id']) || false === filter_var($queryParams['client_id'], FILTER_VALIDATE_URL) || !isClientIdentifier($queryParams['client_id'])) {
- 431 $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
- 432 throw IndieAuthException::create(IndieAuthException::INVALID_CLIENT_ID, $request);
- 433 }
- 434
- 435 // Validate the redirect URI.
- 436 if (!isset($queryParams['redirect_uri']) || false === filter_var($queryParams['redirect_uri'], FILTER_VALIDATE_URL)) {
- 437 $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
- 438 throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
- 439 }
- 440
- 441 // How most errors are handled depends on whether or not the request has a valid redirect_uri. In
- 442 // order to know that, we need to also validate, fetch and parse the client_id.
- 443 // If the request lacks a hash, or if the provided hash was invalid, perform the validation.
- 444 $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret);
- 445 if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($currentRequestHash) or !hash_equals($currentRequestHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
- 446
- 447 // All we need to know at this stage is whether the redirect_uri is valid. If it
- 448 // sufficiently matches the client_id, we don’t (yet) need to fetch the client_id.
- 449 if (!urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT])) {
- 450 // If we do need to fetch the client_id, store the response and effective URL in variables
- 451 // we defined earlier, so they’re available to the approval request code path, which additionally
- 452 // needs to parse client_id for h-app markup.
- 453 try {
- 454 list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
- 455 $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
- 456 } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
- 457 $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
- 458 'client_id' => $queryParams['client_id'],
- 459 'exception' => $e->__toString()
- 460 ]);
- 461
- 462 throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
- 463 } catch (Exception $e) {
- 464 $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
- 465 'exception' => $e->__toString()
- 466 ]);
- 467
- 468 throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
- 469 }
- 470
- 471 // Search for all link@rel=redirect_uri at the client_id.
- 472 $clientIdRedirectUris = [];
- 473 if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) {
- 474 $clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']);
- 475 }
- 476
- 477 foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) {
- 478 if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) {
- 479 // Strip off the < > which surround the link URL for some reason.
- 480 $clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2);
- 481 }
- 482 }
- 483
- 484 // If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error.
- 485 if (!in_array($queryParams['redirect_uri'], $clientIdRedirectUris)) {
- 486 $this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [
- 487 'provided_redirect_uri' => $queryParams['redirect_uri'],
- 488 'provided_client_id' => $queryParams['client_id'],
- 489 'discovered_redirect_uris' => $clientIdRedirectUris
- 490 ]);
- 491
- 492 throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
- 493 }
- 494 }
- 495 }
- 496
- 497 // From now on, we can assume that redirect_uri is valid. Any IndieAuth-related errors should be
- 498 // reported by redirecting to redirect_uri with error parameters.
- 499
- 500 // Validate the state parameter.
- 501 if (!isset($queryParams['state']) or !isValidState($queryParams['state'])) {
- 502 $this->logger->warning("The state provided in an authorization request was not valid.", $queryParams);
- 503 throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request);
- 504 }
+ 424 // Because the special case above isn’t allowed to be CSRF-protected, we have to do some rather silly
+ 425 // closure gymnastics here to selectively-CSRF-protect requests which do need it.
+ 426 return $this->csrfMiddleware->process($request, new Middleware\ClosureRequestHandler(function (ServerRequestInterface $request) {
+ 427 // Wrap the entire user-facing handler in a try/catch block which catches any exception, converts it
+ 428 // to IndieAuthException if necessary, then passes it to $this->handleException() to be turned into a
+ 429 // response.
+ 430 try {
+ 431 $queryParams = $request->getQueryParams();
+ 432
+ 433 /** @var ResponseInterface|null $clientIdResponse */
+ 434 /** @var string|null $clientIdEffectiveUrl */
+ 435 /** @var array|null $clientIdMf2 */
+ 436 list($clientIdResponse, $clientIdEffectiveUrl, $clientIdMf2) = [null, null, null];
+ 437
+ 438 // If this is an authorization or approval request (allowing POST requests as well to accommodate
+ 439 // approval requests and custom auth form submission.
+ 440 if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
+ 441 $this->logger->info('Handling an authorization request', ['method' => $request->getMethod()]);
+ 442
+ 443 // Validate the Client ID.
+ 444 if (!isset($queryParams['client_id']) || false === filter_var($queryParams['client_id'], FILTER_VALIDATE_URL) || !isClientIdentifier($queryParams['client_id'])) {
+ 445 $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
+ 446 throw IndieAuthException::create(IndieAuthException::INVALID_CLIENT_ID, $request);
+ 447 }
+ 448
+ 449 // Validate the redirect URI.
+ 450 if (!isset($queryParams['redirect_uri']) || false === filter_var($queryParams['redirect_uri'], FILTER_VALIDATE_URL)) {
+ 451 $this->logger->warning("The client_id provided in an authorization request was not valid.", $queryParams);
+ 452 throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
+ 453 }
+ 454
+ 455 // How most errors are handled depends on whether or not the request has a valid redirect_uri. In
+ 456 // order to know that, we need to also validate, fetch and parse the client_id.
+ 457 // If the request lacks a hash, or if the provided hash was invalid, perform the validation.
+ 458 $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce);
+ 459 if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($currentRequestHash) or !hash_equals($currentRequestHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
+ 460
+ 461 // All we need to know at this stage is whether the redirect_uri is valid. If it
+ 462 // sufficiently matches the client_id, we don’t (yet) need to fetch the client_id.
+ 463 if (!urlComponentsMatch($queryParams['client_id'], $queryParams['redirect_uri'], [PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PORT])) {
+ 464 // If we do need to fetch the client_id, store the response and effective URL in variables
+ 465 // we defined earlier, so they’re available to the approval request code path, which additionally
+ 466 // needs to parse client_id for h-app markup.
+ 467 try {
+ 468 list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
+ 469 $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
+ 470 } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
+ 471 $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
+ 472 'client_id' => $queryParams['client_id'],
+ 473 'exception' => $e->__toString()
+ 474 ]);
+ 475
+ 476 throw IndieAuthException::create(IndieAuthException::HTTP_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
+ 477 } catch (Exception $e) {
+ 478 $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
+ 479 'exception' => $e->__toString()
+ 480 ]);
+ 481
+ 482 throw IndieAuthException::create(IndieAuthException::INTERNAL_EXCEPTION_FETCHING_CLIENT_ID, $request, $e);
+ 483 }
+ 484
+ 485 // Search for all link@rel=redirect_uri at the client_id.
+ 486 $clientIdRedirectUris = [];
+ 487 if (array_key_exists('redirect_uri', $clientIdMf2['rels'])) {
+ 488 $clientIdRedirectUris = array_merge($clientIdRedirectUris, $clientIdMf2['rels']['redirect_uri']);
+ 489 }
+ 490
+ 491 foreach (HeaderParser::parse($clientIdResponse->getHeader('Link')) as $link) {
+ 492 if (array_key_exists('rel', $link) && mb_strpos(" {$link['rel']} ", " redirect_uri ") !== false) {
+ 493 // Strip off the < > which surround the link URL for some reason.
+ 494 $clientIdRedirectUris[] = substr($link[0], 1, strlen($link[0]) - 2);
+ 495 }
+ 496 }
+ 497
+ 498 // If the authority of the redirect_uri does not match the client_id, or exactly match one of their redirect URLs, return an error.
+ 499 if (!in_array($queryParams['redirect_uri'], $clientIdRedirectUris)) {
+ 500 $this->logger->warning("The provided redirect_uri did not match either the client_id, nor the discovered redirect URIs.", [
+ 501 'provided_redirect_uri' => $queryParams['redirect_uri'],
+ 502 'provided_client_id' => $queryParams['client_id'],
+ 503 'discovered_redirect_uris' => $clientIdRedirectUris
+ 504 ]);
505
- 506 // Validate code_challenge parameter.
- 507 if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) {
- 508 $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams);
- 509 throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
- 510 }
- 511
- 512 if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) {
- 513 $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams);
- 514 throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
- 515 }
- 516
- 517 // From now on, any redirect error responses should include the state parameter.
- 518 // This is handled automatically in `handleException()` and is only noted here
- 519 // for reference.
- 520
- 521 // Validate the scope parameter, if provided.
- 522 if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) {
- 523 $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams);
- 524 throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request);
- 525 }
- 526
- 527 // Normalise the me parameter, if it exists.
- 528 if (array_key_exists('me', $queryParams)) {
- 529 $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
- 530 // If the me parameter is not a valid profile URL, ignore it.
- 531 if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) {
- 532 $queryParams['me'] = null;
+ 506 throw IndieAuthException::create(IndieAuthException::INVALID_REDIRECT_URI, $request);
+ 507 }
+ 508 }
+ 509 }
+ 510
+ 511 // From now on, we can assume that redirect_uri is valid. Any IndieAuth-related errors should be
+ 512 // reported by redirecting to redirect_uri with error parameters.
+ 513
+ 514 // Validate the state parameter.
+ 515 if (!isset($queryParams['state']) or !isValidState($queryParams['state'])) {
+ 516 $this->logger->warning("The state provided in an authorization request was not valid.", $queryParams);
+ 517 throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request);
+ 518 }
+ 519 // From now on, any redirect error responses should include the state parameter.
+ 520 // This is handled automatically in `handleException()` and is only noted here
+ 521 // for reference.
+ 522
+ 523 // If either PKCE parameter is present, validate both.
+ 524 if (isset($queryParams['code_challenge']) or isset($queryParams['code_challenge_method'])) {
+ 525 if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) {
+ 526 $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams);
+ 527 throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
+ 528 }
+ 529
+ 530 if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) {
+ 531 $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams);
+ 532 throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
533 }
- 534 }
- 535
- 536 // Build a URL containing the indieauth authorization request parameters, hashing them
- 537 // to protect them from being changed.
- 538 // Make a hash of the protected indieauth-specific parameters.
- 539 $hash = hashAuthorizationRequestParameters($request, $this->secret);
- 540 // Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
- 541 $redirectQueryParams = $queryParams;
- 542 $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash;
- 543 $authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString();
- 544
- 545 // User-facing requests always start by calling the authentication request callback.
- 546 $this->logger->info('Calling handle_authentication_request callback');
- 547 $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null);
- 548
- 549 // If the authentication handler returned a Response, return that as-is.
- 550 if ($authenticationResult instanceof ResponseInterface) {
- 551 return $authenticationResult;
- 552 } elseif (is_array($authenticationResult)) {
- 553 // Check the resulting array for errors.
- 554 if (!array_key_exists('me', $authenticationResult)) {
- 555 $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]);
- 556 throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request);
- 557 }
- 558
- 559 // If this is a POST request sent from the authorization (i.e. scope-choosing) form:
- 560 if (isAuthorizationApprovalRequest($request)) {
- 561 // Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
- 562 // authorization request parameters from being changed, e.g. by a malicious script which
- 563 // found its way onto the authorization form.
- 564 if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) {
- 565 $this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
- 566 throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request);
- 567 }
- 568
- 569 $expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
- 570 if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
- 571 $this->logger->warning("The hash provided in the URL was invalid!", [
- 572 'expected' => $expectedHash,
- 573 'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
- 574 ]);
- 575 throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request);
- 576 }
- 577
- 578 // Assemble the data for the authorization code, store it somewhere persistent.
- 579 $code = array_merge($authenticationResult, [
- 580 'client_id' => $queryParams['client_id'],
- 581 'redirect_uri' => $queryParams['redirect_uri'],
- 582 'state' => $queryParams['state'],
- 583 'code_challenge' => $queryParams['code_challenge'],
- 584 'code_challenge_method' => $queryParams['code_challenge_method'],
- 585 'requested_scope' => $queryParams['scope'] ?? '',
- 586 ]);
- 587
- 588 // Pass it to the auth code customisation callback.
- 589 $code = $this->authorizationForm->transformAuthorizationCode($request, $code);
+ 534 } else {
+ 535 // If neither PKCE parameter is defined, and PKCE is required, throw an error. Otherwise, proceed.
+ 536 if ($this->requirePkce) {
+ 537 $this->logger->warning("PKCE is required, and both code_challenge and code_challenge_method were missing.");
+ 538 throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST_REDIRECT, $request);
+ 539 }
+ 540 }
+ 541
+ 542 // Validate the scope parameter, if provided.
+ 543 if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) {
+ 544 $this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams);
+ 545 throw IndieAuthException::create(IndieAuthException::INVALID_SCOPE, $request);
+ 546 }
+ 547
+ 548 // Normalise the me parameter, if it exists.
+ 549 if (array_key_exists('me', $queryParams)) {
+ 550 $queryParams['me'] = IndieAuthClient::normalizeMeURL($queryParams['me']);
+ 551 // If the me parameter is not a valid profile URL, ignore it.
+ 552 if (false === $queryParams['me'] || !isProfileUrl($queryParams['me'])) {
+ 553 $queryParams['me'] = null;
+ 554 }
+ 555 }
+ 556
+ 557 // Build a URL containing the indieauth authorization request parameters, hashing them
+ 558 // to protect them from being changed.
+ 559 // Make a hash of the protected indieauth-specific parameters. If PKCE is in use, include
+ 560 // the PKCE parameters in the hash. Otherwise, leave them out.
+ 561 $hash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce);
+ 562 // Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
+ 563 $redirectQueryParams = $queryParams;
+ 564 $redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash;
+ 565 $authenticationRedirect = $request->getUri()->withQuery(buildQueryString($redirectQueryParams))->__toString();
+ 566
+ 567 // User-facing requests always start by calling the authentication request callback.
+ 568 $this->logger->info('Calling handle_authentication_request callback');
+ 569 $authenticationResult = call_user_func($this->handleAuthenticationRequestCallback, $request, $authenticationRedirect, $queryParams['me'] ?? null);
+ 570
+ 571 // If the authentication handler returned a Response, return that as-is.
+ 572 if ($authenticationResult instanceof ResponseInterface) {
+ 573 return $authenticationResult;
+ 574 } elseif (is_array($authenticationResult)) {
+ 575 // Check the resulting array for errors.
+ 576 if (!array_key_exists('me', $authenticationResult)) {
+ 577 $this->logger->error('The handle_authentication_request callback returned an array with no me key.', ['array' => $authenticationResult]);
+ 578 throw IndieAuthException::create(IndieAuthException::AUTHENTICATION_CALLBACK_MISSING_ME_PARAM, $request);
+ 579 }
+ 580
+ 581 // If this is a POST request sent from the authorization (i.e. scope-choosing) form:
+ 582 if (isAuthorizationApprovalRequest($request)) {
+ 583 // Authorization approval requests MUST include a hash protecting the sensitive IndieAuth
+ 584 // authorization request parameters from being changed, e.g. by a malicious script which
+ 585 // found its way onto the authorization form.
+ 586 if (!array_key_exists(self::HASH_QUERY_STRING_KEY, $queryParams)) {
+ 587 $this->logger->warning("An authorization approval request did not have a " . self::HASH_QUERY_STRING_KEY . " parameter.");
+ 588 throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request);
+ 589 }
590
- 591 // Store the authorization code.
- 592 $authCode = $this->tokenStorage->createAuthCode($code);
- 593 if (is_null($authCode)) {
- 594 // If saving the authorization code failed silently, there isn’t much we can do about it,
- 595 // but should at least log and return an error.
- 596 $this->logger->error("Saving the authorization code failed and returned false without raising an exception.");
- 597 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request);
+ 591 $expectedHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce);
+ 592 if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
+ 593 $this->logger->warning("The hash provided in the URL was invalid!", [
+ 594 'expected' => $expectedHash,
+ 595 'actual' => $queryParams[self::HASH_QUERY_STRING_KEY]
+ 596 ]);
+ 597 throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH, $request);
598 }
599
- 600 // Return a redirect to the client app.
- 601 return new Response(302, [
- 602 'Location' => appendQueryParams($queryParams['redirect_uri'], [
- 603 'code' => $authCode,
- 604 'state' => $code['state']
- 605 ]),
- 606 'Cache-control' => 'no-cache'
- 607 ]);
- 608 }
+ 600 // Assemble the data for the authorization code, store it somewhere persistent.
+ 601 $code = array_merge($authenticationResult, [
+ 602 'client_id' => $queryParams['client_id'],
+ 603 'redirect_uri' => $queryParams['redirect_uri'],
+ 604 'state' => $queryParams['state'],
+ 605 'code_challenge' => $queryParams['code_challenge'] ?? null,
+ 606 'code_challenge_method' => $queryParams['code_challenge_method'] ?? null,
+ 607 'requested_scope' => $queryParams['scope'] ?? '',
+ 608 ]);
609
- 610 // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
- 611
- 612 // Fetch the client_id URL to find information about the client to present to the user.
- 613 // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1,
- 614 // it may be necessary to do this before returning any other kind of error response, as, per
- 615 // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters
- 616 // are missing or invalid. Otherwise, they should be sent back to the client with an error
- 617 // redirect response.
- 618 if (is_null($clientIdResponse) || is_null($clientIdEffectiveUrl) || is_null($clientIdMf2)) {
- 619 try {
- 620 /** @var ResponseInterface $clientIdResponse */
- 621 list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
- 622 $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
- 623 } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
- 624 $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
- 625 'client_id' => $queryParams['client_id'],
- 626 'exception' => $e->__toString()
- 627 ]);
- 628
- 629 // At this point in the flow, we’ve already guaranteed that the redirect_uri is valid,
- 630 // so in theory we should report these errors by redirecting there.
- 631 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e);
- 632 } catch (Exception $e) {
- 633 $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
- 634 'exception' => $e->__toString()
- 635 ]);
- 636
- 637 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e);
- 638 }
- 639 }
- 640
- 641 // Search for an h-app with u-url matching the client_id.
- 642 $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']);
- 643 $clientHApp = empty($clientHApps) ? null : $clientHApps[0];
- 644
- 645 // Present the authorization UI.
- 646 return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp)
- 647 ->withAddedHeader('Cache-control', 'no-cache');
- 648 }
- 649 }
- 650
- 651 // If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
- 652 // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
- 653 $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request);
- 654 if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
- 655 return $nonIndieAuthRequestResult;
- 656 } else {
- 657 // In this code path we have not validated the redirect_uri, so show a regular error page
- 658 // rather than returning a redirect error.
- 659 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request);
- 660 }
- 661 } catch (IndieAuthException $e) {
- 662 // All IndieAuthExceptions will already have been logged.
- 663 return $this->handleException($e);
- 664 } catch (Exception $e) {
- 665 // Unknown exceptions will not have been logged; do so now.
- 666 $this->logger->error("Caught unknown exception: {$e}");
- 667 return $this->handleException(IndieAuthException::create(0, $request, $e));
- 668 }
- 669 }));
- 670 }
- 671
- 672 /**
- 673 * Handle Token Endpoint Request
- 674 *
- 675 * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows:
- 676 *
- 677 * * Check that the request is a code redeeming request. Return an error if not.
- 678 * * Ensure that all required parameters are present. Return an error if not.
- 679 * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails.
- 680 * * Make sure the client_id and redirect_uri request parameters match those stored in the auth code. If not, revoke the access token and return an error.
- 681 * * Make sure the provided code_verifier hashes to the code_challenge stored in the auth code. If not, revoke the access token and return an error.
- 682 * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error.
- 683 * * Otherwise, return a success response containing information about the issued access token.
- 684 *
- 685 * This method must NOT be CSRF-protected as it accepts external requests from client apps.
- 686 *
- 687 * @param ServerRequestInterface $request
- 688 * @return ResponseInterface
- 689 */
- 690 public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
- 691 if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
- 692 $this->logger->info('Handling a request to redeem an authorization code for profile information.');
- 693
- 694 $bodyParams = $request->getParsedBody();
- 695
- 696 if (!isset($bodyParams['code'])) {
- 697 $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.');
- 698 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 699 'error' => 'invalid_request',
- 700 'error_description' => 'The code parameter was missing.'
- 701 ]));
- 702 }
- 703
- 704 // Attempt to internally exchange the provided auth code for an access token.
- 705 // We do this before anything else so that the auth code is invalidated as soon as the request starts,
- 706 // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler
- 707 // and more flexible interface for TokenStorage implementors.
- 708 try {
- 709 // Call the token exchange method, passing in a callback which performs additional validation
- 710 // on the auth code before it gets exchanged.
- 711 $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
- 712 // Verify that all required parameters are included.
- 713 $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier'];
- 714 $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
- 715 return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
- 716 });
- 717 if (!empty($missingRequiredParameters)) {
- 718 $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
- 719 throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request);
- 720 }
- 721
- 722 // Verify that it was issued for the same client_id and redirect_uri
- 723 if ($authCode['client_id'] !== $bodyParams['client_id']
- 724 || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) {
- 725 $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token.");
- 726 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
- 727 }
- 728
- 729 // Check that the supplied code_verifier hashes to the stored code_challenge
- 730 // TODO: support method = plain as well as S256.
- 731 if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
- 732 $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
- 733 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
- 734 }
- 735
- 736 // Check that scope is not empty.
- 737 if (empty($authCode['scope'])) {
- 738 $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint.");
- 739 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
- 740 }
- 741 });
- 742 } catch (IndieAuthException $e) {
- 743 // If an exception was thrown, return a corresponding error response.
- 744 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 745 'error' => $e->getInfo()['error'],
- 746 'error_description' => $e->getMessage()
- 747 ]));
- 748 }
- 749
- 750 if (is_null($tokenData)) {
- 751 $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams);
- 752 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 753 'error' => 'invalid_grant',
- 754 'error_description' => 'The provided credentials were not valid.'
- 755 ]));
- 756 }
+ 610 // Pass it to the auth code customisation callback.
+ 611 $code = $this->authorizationForm->transformAuthorizationCode($request, $code);
+ 612
+ 613 // Store the authorization code.
+ 614 $authCode = $this->tokenStorage->createAuthCode($code);
+ 615 if (is_null($authCode)) {
+ 616 // If saving the authorization code failed silently, there isn’t much we can do about it,
+ 617 // but should at least log and return an error.
+ 618 $this->logger->error("Saving the authorization code failed and returned false without raising an exception.");
+ 619 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request);
+ 620 }
+ 621
+ 622 // Return a redirect to the client app.
+ 623 return new Response(302, [
+ 624 'Location' => appendQueryParams($queryParams['redirect_uri'], [
+ 625 'code' => $authCode,
+ 626 'state' => $code['state']
+ 627 ]),
+ 628 'Cache-control' => 'no-cache'
+ 629 ]);
+ 630 }
+ 631
+ 632 // Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
+ 633
+ 634 // Fetch the client_id URL to find information about the client to present to the user.
+ 635 // TODO: in order to comply with https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1,
+ 636 // it may be necessary to do this before returning any other kind of error response, as, per
+ 637 // the spec, errors should only be shown to the user if the client_id and redirect_uri parameters
+ 638 // are missing or invalid. Otherwise, they should be sent back to the client with an error
+ 639 // redirect response.
+ 640 if (is_null($clientIdResponse) || is_null($clientIdEffectiveUrl) || is_null($clientIdMf2)) {
+ 641 try {
+ 642 /** @var ResponseInterface $clientIdResponse */
+ 643 list($clientIdResponse, $clientIdEffectiveUrl) = call_user_func($this->httpGetWithEffectiveUrl, $queryParams['client_id']);
+ 644 $clientIdMf2 = Mf2\parse((string) $clientIdResponse->getBody(), $clientIdEffectiveUrl);
+ 645 } catch (ClientExceptionInterface | RequestExceptionInterface | NetworkExceptionInterface $e) {
+ 646 $this->logger->error("Caught an HTTP exception while trying to fetch the client_id. Returning an error response.", [
+ 647 'client_id' => $queryParams['client_id'],
+ 648 'exception' => $e->__toString()
+ 649 ]);
+ 650
+ 651 // At this point in the flow, we’ve already guaranteed that the redirect_uri is valid,
+ 652 // so in theory we should report these errors by redirecting there.
+ 653 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e);
+ 654 } catch (Exception $e) {
+ 655 $this->logger->error("Caught an unknown exception while trying to fetch the client_id. Returning an error response.", [
+ 656 'exception' => $e->__toString()
+ 657 ]);
+ 658
+ 659 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR_REDIRECT, $request, $e);
+ 660 }
+ 661 }
+ 662
+ 663 // Search for an h-app with u-url matching the client_id.
+ 664 $clientHApps = M\findMicroformatsByProperty(M\findMicroformatsByType($clientIdMf2, 'h-app'), 'url', $queryParams['client_id']);
+ 665 $clientHApp = empty($clientHApps) ? null : $clientHApps[0];
+ 666
+ 667 // Present the authorization UI.
+ 668 return $this->authorizationForm->showForm($request, $authenticationResult, $authenticationRedirect, $clientHApp)
+ 669 ->withAddedHeader('Cache-control', 'no-cache');
+ 670 }
+ 671 }
+ 672
+ 673 // If the request isn’t an IndieAuth Authorization or Code-redeeming request, it’s either an invalid
+ 674 // request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
+ 675 $nonIndieAuthRequestResult = call_user_func($this->handleNonIndieAuthRequest, $request);
+ 676 if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
+ 677 return $nonIndieAuthRequestResult;
+ 678 } else {
+ 679 // In this code path we have not validated the redirect_uri, so show a regular error page
+ 680 // rather than returning a redirect error.
+ 681 throw IndieAuthException::create(IndieAuthException::INTERNAL_ERROR, $request);
+ 682 }
+ 683 } catch (IndieAuthException $e) {
+ 684 // All IndieAuthExceptions will already have been logged.
+ 685 return $this->handleException($e);
+ 686 } catch (Exception $e) {
+ 687 // Unknown exceptions will not have been logged; do so now.
+ 688 $this->logger->error("Caught unknown exception: {$e}");
+ 689 return $this->handleException(IndieAuthException::create(0, $request, $e));
+ 690 }
+ 691 }));
+ 692 }
+ 693
+ 694 /**
+ 695 * Handle Token Endpoint Request
+ 696 *
+ 697 * Handles requests to the IndieAuth token endpoint. The logical flow can be summarised as follows:
+ 698 *
+ 699 * * Check that the request is a code redeeming request. Return an error if not.
+ 700 * * Ensure that all required parameters are present. Return an error if not.
+ 701 * * Attempt to exchange the `code` parameter for an access token. Return an error if it fails.
+ 702 * * Make sure the client_id and redirect_uri request parameters match those stored in the auth code. If not, revoke the access token and return an error.
+ 703 * * Make sure the provided code_verifier hashes to the code_challenge stored in the auth code. If not, revoke the access token and return an error.
+ 704 * * Make sure the granted scope stored in the auth code is not empty. If it is, revoke the access token and return an error.
+ 705 * * Otherwise, return a success response containing information about the issued access token.
+ 706 *
+ 707 * This method must NOT be CSRF-protected as it accepts external requests from client apps.
+ 708 *
+ 709 * @param ServerRequestInterface $request
+ 710 * @return ResponseInterface
+ 711 */
+ 712 public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
+ 713 if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
+ 714 $this->logger->info('Handling a request to redeem an authorization code for profile information.');
+ 715
+ 716 $bodyParams = $request->getParsedBody();
+ 717
+ 718 if (!isset($bodyParams['code'])) {
+ 719 $this->logger->warning('The exchange request was missing the code parameter. Returning an error response.');
+ 720 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 721 'error' => 'invalid_request',
+ 722 'error_description' => 'The code parameter was missing.'
+ 723 ]));
+ 724 }
+ 725
+ 726 // Attempt to internally exchange the provided auth code for an access token.
+ 727 // We do this before anything else so that the auth code is invalidated as soon as the request starts,
+ 728 // and the resulting access token is revoked if we encounter an error. This ends up providing a simpler
+ 729 // and more flexible interface for TokenStorage implementors.
+ 730 try {
+ 731 // Call the token exchange method, passing in a callback which performs additional validation
+ 732 // on the auth code before it gets exchanged.
+ 733 $tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
+ 734 // Verify that all required parameters are included.
+ 735 $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri'];
+ 736 $missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
+ 737 return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
+ 738 });
+ 739 if (!empty($missingRequiredParameters)) {
+ 740 $this->logger->warning('The exchange request was missing required parameters. Returning an error response.', ['missing' => $missingRequiredParameters]);
+ 741 throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST, $request);
+ 742 }
+ 743
+ 744 // Verify that it was issued for the same client_id and redirect_uri
+ 745 if ($authCode['client_id'] !== $bodyParams['client_id']
+ 746 || $authCode['redirect_uri'] !== $bodyParams['redirect_uri']) {
+ 747 $this->logger->error("The provided client_id and/or redirect_uri did not match those stored in the token.");
+ 748 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 749 }
+ 750
+ 751 // If the auth code was requested with no code_challenge, but the exchange request provides a
+ 752 // code_verifier, return an error.
+ 753 if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) {
+ 754 $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge.");
+ 755 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 756 }
757
- 758 // TODO: return an error if the token doesn’t contain a me key.
- 759
- 760 // If everything checked out, return {"me": "https://example.com"} response
- 761 return new Response(200, [
- 762 'content-type' => 'application/json',
- 763 'cache-control' => 'no-store',
- 764 ], json_encode(array_merge([
- 765 // Ensure that the token_type key is present, if tokenStorage doesn’t include it.
- 766 'token_type' => 'Bearer'
- 767 ], array_filter($tokenData, function (string $k) {
- 768 // We should be able to trust the return data from tokenStorage, but there’s no harm in
- 769 // preventing code_challenges from leaking, per OAuth2.
- 770 return !in_array($k, ['code_challenge', 'code_challenge_method']);
- 771 }, ARRAY_FILTER_USE_KEY))));
- 772 }
- 773
- 774 return new Response(400, ['content-type' => 'application/json'], json_encode([
- 775 'error' => 'invalid_request',
- 776 'error_description' => 'Request to token endpoint was not a valid code exchange request.'
- 777 ]));
- 778 }
- 779
- 780 /**
- 781 * Handle Exception
- 782 *
- 783 * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`.
- 784 */
- 785 protected function handleException(IndieAuthException $exception): ResponseInterface {
- 786 $exceptionData = $exception->getInfo();
- 787
- 788 if ($exceptionData['statusCode'] == 302) {
- 789 // This exception is handled by redirecting to the redirect_uri with error parameters.
- 790 $redirectQueryParams = [
- 791 'error' => $exceptionData['error'] ?? 'invalid_request',
- 792 'error_description' => (string) $exception
- 793 ];
- 794
- 795 // If the state parameter was valid, include it in the error redirect.
- 796 if ($exception->getCode() !== IndieAuthException::INVALID_STATE) {
- 797 $redirectQueryParams['state'] = $exception->getRequest()->getQueryParams()['state'];
- 798 }
- 799
- 800 return new Response($exceptionData['statusCode'], [
- 801 'Location' => appendQueryParams((string) $exception->getRequest()->getQueryParams()['redirect_uri'], $redirectQueryParams)
- 802 ]);
- 803 } else {
- 804 // This exception should be shown to the user.
- 805 return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [
- 806 'request' => $exception->getRequest(),
- 807 'exception' => $exception
- 808 ]));
- 809 }
- 810 }
- 811 }
+ 758 if ($this->requirePkce or !empty($authCode['code_challenge'])) {
+ 759 // Check that the supplied code_verifier hashes to the stored code_challenge
+ 760 // TODO: support method = plain as well as S256.
+ 761 if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
+ 762 $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
+ 763 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 764 }
+ 765 }
+ 766
+ 767 // Check that scope is not empty.
+ 768 if (empty($authCode['scope'])) {
+ 769 $this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint.");
+ 770 throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ 771 }
+ 772 });
+ 773 } catch (IndieAuthException $e) {
+ 774 // If an exception was thrown, return a corresponding error response.
+ 775 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 776 'error' => $e->getInfo()['error'],
+ 777 'error_description' => $e->getMessage()
+ 778 ]));
+ 779 }
+ 780
+ 781 if (is_null($tokenData)) {
+ 782 $this->logger->error('Attempting to exchange an auth code for a token resulted in null.', $bodyParams);
+ 783 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 784 'error' => 'invalid_grant',
+ 785 'error_description' => 'The provided credentials were not valid.'
+ 786 ]));
+ 787 }
+ 788
+ 789 // TODO: return an error if the token doesn’t contain a me key.
+ 790
+ 791 // If everything checked out, return {"me": "https://example.com"} response
+ 792 return new Response(200, [
+ 793 'content-type' => 'application/json',
+ 794 'cache-control' => 'no-store',
+ 795 ], json_encode(array_merge([
+ 796 // Ensure that the token_type key is present, if tokenStorage doesn’t include it.
+ 797 'token_type' => 'Bearer'
+ 798 ], array_filter($tokenData, function (string $k) {
+ 799 // We should be able to trust the return data from tokenStorage, but there’s no harm in
+ 800 // preventing code_challenges from leaking, per OAuth2.
+ 801 return !in_array($k, ['code_challenge', 'code_challenge_method']);
+ 802 }, ARRAY_FILTER_USE_KEY))));
+ 803 }
+ 804
+ 805 return new Response(400, ['content-type' => 'application/json'], json_encode([
+ 806 'error' => 'invalid_request',
+ 807 'error_description' => 'Request to token endpoint was not a valid code exchange request.'
+ 808 ]));
+ 809 }
+ 810
+ 811 /**
+ 812 * Handle Exception
+ 813 *
+ 814 * Turns an instance of `IndieAuthException` into an appropriate instance of `ResponseInterface`.
+ 815 */
+ 816 protected function handleException(IndieAuthException $exception): ResponseInterface {
+ 817 $exceptionData = $exception->getInfo();
+ 818
+ 819 if ($exceptionData['statusCode'] == 302) {
+ 820 // This exception is handled by redirecting to the redirect_uri with error parameters.
+ 821 $redirectQueryParams = [
+ 822 'error' => $exceptionData['error'] ?? 'invalid_request',
+ 823 'error_description' => (string) $exception
+ 824 ];
+ 825
+ 826 // If the state parameter was valid, include it in the error redirect.
+ 827 if ($exception->getCode() !== IndieAuthException::INVALID_STATE) {
+ 828 $redirectQueryParams['state'] = $exception->getRequest()->getQueryParams()['state'];
+ 829 }
+ 830
+ 831 return new Response($exceptionData['statusCode'], [
+ 832 'Location' => appendQueryParams((string) $exception->getRequest()->getQueryParams()['redirect_uri'], $redirectQueryParams)
+ 833 ]);
+ 834 } else {
+ 835 // This exception should be shown to the user.
+ 836 return new Response($exception->getStatusCode(), ['content-type' => 'text/html'], renderTemplate($this->exceptionTemplatePath, [
+ 837 'request' => $exception->getRequest(),
+ 838 'exception' => $exception
+ 839 ]));
+ 840 }
+ 841 }
+ 842 }
@@ -1031,7 +1062,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Storage/FilesystemJsonStorage.php.html b/docs/coverage/Storage/FilesystemJsonStorage.php.html
index b5aca98..3fdcd24 100644
--- a/docs/coverage/Storage/FilesystemJsonStorage.php.html
+++ b/docs/coverage/Storage/FilesystemJsonStorage.php.html
@@ -419,110 +419,110 @@
38
39
40 public function __construct(string $path, string $secret, ?int $authCodeTtl=null, ?int $accessTokenTtl=null, $cleanUpNow=false, ?LoggerInterface $logger=null) {
- 41 $this->logger = $logger ?? new NullLogger();
+ 41 $this->logger = $logger ?? new NullLogger();
42
- 43 if (strlen($secret) < 64) {
+ 43 if (strlen($secret) < 64) {
44 throw new Exception("\$secret must be a string with a minimum length of 64 characters. Make one with Taproot\IndieAuth\generateRandomString(64)");
45 }
- 46 $this->secret = $secret;
+ 46 $this->secret = $secret;
47
- 48 $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ 48 $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
49
- 50 $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL;
- 51 $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL;
+ 50 $this->authCodeTtl = $authCodeTtl ?? self::DEFAULT_AUTH_CODE_TTL;
+ 51 $this->accessTokenTtl = $accessTokenTtl ?? self::DEFAULT_ACCESS_TOKEN_TTL;
52
- 53 @mkdir($this->path, 0777, true);
+ 53 @mkdir($this->path, 0777, true);
54
- 55 if ($cleanUpNow) {
+ 55 if ($cleanUpNow) {
56 $this->deleteExpiredTokens();
57 }
- 58 }
+ 58 }
59
60 // LoggerAwareInterface method.
61
62 public function setLogger(LoggerInterface $logger) {
- 63 $this->logger = $logger;
- 64 }
+ 63 $this->logger = $logger;
+ 64 }
65
66 // TokenStorageInterface Methods.
67
68 public function createAuthCode(array $data): ?string {
- 69 $authCode = generateRandomString(self::TOKEN_LENGTH);
- 70 $accessToken = $this->hash($authCode);
+ 69 $authCode = generateRandomString(self::TOKEN_LENGTH);
+ 70 $accessToken = $this->hash($authCode);
71
- 72 if (!array_key_exists('valid_until', $data)) {
- 73 $data['valid_until'] = time() + $this->authCodeTtl;
+ 72 if (!array_key_exists('valid_until', $data)) {
+ 73 $data['valid_until'] = time() + $this->authCodeTtl;
74 }
75
- 76 if (!$this->put($accessToken, $data)) {
+ 76 if (!$this->put($accessToken, $data)) {
77 return null;
78 }
- 79 return $authCode;
+ 79 return $authCode;
80 }
81
82 public function exchangeAuthCodeForAccessToken(string $code, callable $validateAuthCode): ?array {
83 // Hash the auth code to get the theoretical matching access token filename.
- 84 $accessToken = $this->hash($code);
+ 84 $accessToken = $this->hash($code);
85
86 // Prevent the token file from being read, modified or deleted while we’re working with it.
87 // r+ to allow reading and writing, but to make sure we don’t create the file if it doesn’t
88 // already exist.
- 89 return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) {
+ 89 return $this->withLock($this->getPath($accessToken), 'r+', function ($fp) use ($accessToken, $validateAuthCode) {
90 // Read the file contents.
- 91 $fileContents = '';
- 92 while ($d = fread($fp, 1024)) { $fileContents .= $d; }
+ 91 $fileContents = '';
+ 92 while ($d = fread($fp, 1024)) { $fileContents .= $d; }
93
- 94 $data = json_decode($fileContents, true);
+ 94 $data = json_decode($fileContents, true);
95
- 96 if (!is_array($data)) { return null; }
+ 96 if (!is_array($data)) { return null; }
97
98 // Make sure the auth code hasn’t already been redeemed.
- 99 if ($data['exchanged_at'] ?? false) { return null; }
+ 99 if ($data['exchanged_at'] ?? false) { return null; }
100
101 // Make sure the auth code isn’t expired.
- 102 if (($data['valid_until'] ?? 0) < time()) { return null; }
+ 102 if (($data['valid_until'] ?? 0) < time()) { return null; }
103
104 // The auth code is valid as far as we know, pass it to the validation callback passed from the
105 // Server.
106 try {
- 107 $validateAuthCode($data);
- 108 } catch (IndieAuthException $e) {
+ 107 $validateAuthCode($data);
+ 108 } catch (IndieAuthException $e) {
109 // If there was an issue with the auth code, delete it before bubbling the exception
110 // up to the Server for handling. We currently have a lock on the file path, so pass
111 // false to $observeLock to prevent a deadlock.
- 112 $this->delete($accessToken, false);
- 113 throw $e;
+ 112 $this->delete($accessToken, false);
+ 113 throw $e;
114 }
115
116 // If the access token is valid, mark it as redeemed and set a new expiry time.
- 117 $data['exchanged_at'] = time();
+ 117 $data['exchanged_at'] = time();
118
- 119 if (is_int($data['_access_token_ttl'] ?? null)) {
+ 119 if (is_int($data['_access_token_ttl'] ?? null)) {
120 // This access token has a custom TTL, use that.
121 $data['valid_until'] = time() + $data['_access_code_ttl'];
- 122 } elseif ($this->accessTokenTtl == 0) {
+ 122 } elseif ($this->accessTokenTtl == 0) {
123 // The token should be valid until explicitly revoked.
124 $data['valid_until'] = null;
125 } else {
126 // Use the default TTL.
- 127 $data['valid_until'] = time() + $this->accessTokenTtl;
+ 127 $data['valid_until'] = time() + $this->accessTokenTtl;
128 }
129
130 // Write the new file contents, truncating afterwards in case the new data is shorter than the old data.
- 131 $jsonData = json_encode($data);
- 132 if (rewind($fp) === false) { return null; }
- 133 if (fwrite($fp, $jsonData) === false) { return null; }
- 134 if (ftruncate($fp, strlen($jsonData)) === false) { return null; }
+ 131 $jsonData = json_encode($data);
+ 132 if (rewind($fp) === false) { return null; }
+ 133 if (fwrite($fp, $jsonData) === false) { return null; }
+ 134 if (ftruncate($fp, strlen($jsonData)) === false) { return null; }
135
136 // Return the OAuth2-compatible access token data to the Server for passing onto
137 // the client app. Passed via array_filter to remove the scope key if scope is null.
- 138 return array_filter([
- 139 'access_token' => $accessToken,
- 140 'scope' => $data['scope'] ?? null,
- 141 'me' => $data['me'],
- 142 'profile' => $data['profile'] ?? null
+ 138 return array_filter([
+ 139 'access_token' => $accessToken,
+ 140 'scope' => $data['scope'] ?? null,
+ 141 'me' => $data['me'],
+ 142 'profile' => $data['profile'] ?? null
143 ]);
- 144 });
+ 144 });
145 }
146
147 public function getAccessToken(string $token): ?array {
@@ -599,22 +599,22 @@
218
219 public function put(string $key, array $data): bool {
220 // Ensure that the containing folder exists.
- 221 @mkdir($this->path, 0777, true);
+ 221 @mkdir($this->path, 0777, true);
222
- 223 return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) {
- 224 return fwrite($fp, json_encode($data)) !== false;
- 225 });
+ 223 return $this->withLock($this->getPath($key), 'w', function ($fp) use ($data) {
+ 224 return fwrite($fp, json_encode($data)) !== false;
+ 225 });
226 }
227
228 public function delete(string $key, $observeLock=true): bool {
- 229 $path = $this->getPath($key);
- 230 if (file_exists($path)) {
- 231 if ($observeLock) {
+ 229 $path = $this->getPath($key);
+ 230 if (file_exists($path)) {
+ 231 if ($observeLock) {
232 return $this->withLock($path, 'r', function ($fp) use ($path) {
233 return unlink($path);
234 });
235 } else {
- 236 return unlink($path);
+ 236 return unlink($path);
237 }
238 }
239 return false;
@@ -622,35 +622,35 @@
241
242 public function getPath(string $key): string {
243 // TODO: ensure that the calculated path is a child of $this->path.
- 244 return $this->path . "$key.json";
+ 244 return $this->path . "$key.json";
245 }
246
247 protected function withLock(string $path, string $mode, callable $callback) {
- 248 $fp = @fopen($path, $mode);
+ 248 $fp = @fopen($path, $mode);
249
- 250 if ($fp === false) {
+ 250 if ($fp === false) {
251 return null;
252 }
253
254 // Wait for a lock.
- 255 if (flock($fp, LOCK_EX)) {
- 256 $return = null;
+ 255 if (flock($fp, LOCK_EX)) {
+ 256 $return = null;
257 try {
258 // Perform whatever action on the file pointer.
- 259 $return = $callback($fp);
- 260 } finally {
+ 259 $return = $callback($fp);
+ 260 } finally {
261 // Regardless of what happens, release the lock.
- 262 flock($fp, LOCK_UN);
- 263 fclose($fp);
+ 262 flock($fp, LOCK_UN);
+ 263 fclose($fp);
264 }
- 265 return $return;
+ 265 return $return;
266 }
267 // It wasn’t possible to get a lock.
268 return null;
269 }
270
271 protected function hash(string $token): string {
- 272 return hash_hmac('sha256', $token, $this->secret);
+ 272 return hash_hmac('sha256', $token, $this->secret);
273 }
274 }
@@ -663,7 +663,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Storage/Sqlite3Storage.php.html b/docs/coverage/Storage/Sqlite3Storage.php.html
index 2d01a02..a4cca04 100644
--- a/docs/coverage/Storage/Sqlite3Storage.php.html
+++ b/docs/coverage/Storage/Sqlite3Storage.php.html
@@ -79,7 +79,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Storage/TokenStorageInterface.php.html b/docs/coverage/Storage/TokenStorageInterface.php.html
index c38a5f4..b546673 100644
--- a/docs/coverage/Storage/TokenStorageInterface.php.html
+++ b/docs/coverage/Storage/TokenStorageInterface.php.html
@@ -235,7 +235,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/Storage/dashboard.html b/docs/coverage/Storage/dashboard.html
index e4064c9..267f0c0 100644
--- a/docs/coverage/Storage/dashboard.html
+++ b/docs/coverage/Storage/dashboard.html
@@ -142,7 +142,7 @@
diff --git a/docs/coverage/Storage/index.html b/docs/coverage/Storage/index.html
index be1da76..6614179 100644
--- a/docs/coverage/Storage/index.html
+++ b/docs/coverage/Storage/index.html
@@ -137,7 +137,7 @@
High: 90% to 100%
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/dashboard.html b/docs/coverage/dashboard.html
index 25b40f1..f631cf1 100644
--- a/docs/coverage/dashboard.html
+++ b/docs/coverage/dashboard.html
@@ -113,11 +113,11 @@
- getExplanation 0%
- trustQueryParams 0%
+ getExplanation 0%
+ trustQueryParams 0%
process 0%
__construct 81%
- create 83%
+ create 83%
createAuthCode 85%
delete 87%
@@ -136,11 +136,11 @@
- trustQueryParams 6
+ trustQueryParams 6
__construct 3
createAuthCode 3
delete 3
- create 2
+ create 2
@@ -150,7 +150,7 @@
@@ -187,7 +187,7 @@ $(document).ready(function() {
.yAxis.tickFormat(d3.format('d'));
d3.select('#methodCoverageDistribution svg')
- .datum(getCoverageDistributionData([3,0,0,0,0,0,0,0,0,4,6,26], "Method Coverage"))
+ .datum(getCoverageDistributionData([3,0,0,0,0,0,0,0,0,4,5,27], "Method Coverage"))
.transition().duration(500).call(chart);
nv.utils.windowResize(chart.update);
@@ -237,7 +237,7 @@ $(document).ready(function() {
chart.yAxis.axisLabel('Cyclomatic Complexity');
d3.select('#classComplexity svg')
- .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,6,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"],[66.66666666666666,8,"Taproot\\IndieAuth\\IndieAuthException<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler<\/a>"],[96.875,12,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler<\/a>"],[94.98069498069498,90,"Taproot\\IndieAuth\\Server<\/a>"],[92.3076923076923,46,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage<\/a>"]], 'Class Complexity'))
+ .datum(getComplexityData([[100,6,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm<\/a>"],[100,6,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback<\/a>"],[66.66666666666666,8,"Taproot\\IndieAuth\\IndieAuthException<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler<\/a>"],[96.875,12,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware<\/a>"],[100,2,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler<\/a>"],[96.69117647058823,105,"Taproot\\IndieAuth\\Server<\/a>"],[92.3076923076923,46,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage<\/a>"]], 'Class Complexity'))
.transition()
.duration(500)
.call(chart);
@@ -261,7 +261,7 @@ $(document).ready(function() {
chart.yAxis.axisLabel('Method Complexity');
d3.select('#methodComplexity svg')
- .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::showForm<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::transformAuthorizationCode<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__invoke<\/a>"],[83.33333333333334,2,"Taproot\\IndieAuth\\IndieAuthException::create<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getStatusCode<\/a>"],[0,1,"Taproot\\IndieAuth\\IndieAuthException::getExplanation<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getInfo<\/a>"],[0,2,"Taproot\\IndieAuth\\IndieAuthException::trustQueryParams<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getRequest<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::handle<\/a>"],[92.85714285714286,5,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::process<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::isValid<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware::process<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::handle<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Server::getTokenStorage<\/a>"],[93.08176100628931,58,"Taproot\\IndieAuth\\Server::handleAuthorizationEndpointRequest<\/a>"],[95.34883720930233,11,"Taproot\\IndieAuth\\Server::handleTokenEndpointRequest<\/a>"],[100,3,"Taproot\\IndieAuth\\Server::handleException<\/a>"],[81.81818181818183,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::setLogger<\/a>"],[85.71428571428571,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[92.85714285714286,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[100,5,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getAccessToken<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::revokeAccessToken<\/a>"],[100,9,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::deleteExpiredTokens<\/a>"],[91.66666666666666,4,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::get<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::put<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::delete<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getPath<\/a>"],[90.9090909090909,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::withLock<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::hash<\/a>"]], 'Method Complexity'))
+ .datum(getComplexityData([[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::showForm<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::transformAuthorizationCode<\/a>"],[100,1,"Taproot\\IndieAuth\\Callback\\DefaultAuthorizationForm::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__construct<\/a>"],[100,3,"Taproot\\IndieAuth\\Callback\\SingleUserPasswordAuthenticationCallback::__invoke<\/a>"],[83.33333333333334,2,"Taproot\\IndieAuth\\IndieAuthException::create<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getStatusCode<\/a>"],[0,1,"Taproot\\IndieAuth\\IndieAuthException::getExplanation<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getInfo<\/a>"],[0,2,"Taproot\\IndieAuth\\IndieAuthException::trustQueryParams<\/a>"],[100,1,"Taproot\\IndieAuth\\IndieAuthException::getRequest<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ClosureRequestHandler::handle<\/a>"],[92.85714285714286,5,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::setLogger<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::process<\/a>"],[100,3,"Taproot\\IndieAuth\\Middleware\\DoubleSubmitCookieCsrfMiddleware::isValid<\/a>"],[0,1,"Taproot\\IndieAuth\\Middleware\\NoOpMiddleware::process<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Middleware\\ResponseRequestHandler::handle<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Server::getTokenStorage<\/a>"],[94.61077844311377,67,"Taproot\\IndieAuth\\Server::handleAuthorizationEndpointRequest<\/a>"],[100,17,"Taproot\\IndieAuth\\Server::handleTokenEndpointRequest<\/a>"],[100,3,"Taproot\\IndieAuth\\Server::handleException<\/a>"],[81.81818181818183,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::__construct<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::setLogger<\/a>"],[85.71428571428571,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::createAuthCode<\/a>"],[92.85714285714286,11,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::exchangeAuthCodeForAccessToken<\/a>"],[100,5,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getAccessToken<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::revokeAccessToken<\/a>"],[100,9,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::deleteExpiredTokens<\/a>"],[91.66666666666666,4,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::get<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::put<\/a>"],[87.5,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::delete<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::getPath<\/a>"],[90.9090909090909,3,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::withLock<\/a>"],[100,1,"Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage::hash<\/a>"]], 'Method Complexity'))
.transition()
.duration(500)
.call(chart);
diff --git a/docs/coverage/functions.php.html b/docs/coverage/functions.php.html
index c9fa574..7c7bf44 100644
--- a/docs/coverage/functions.php.html
+++ b/docs/coverage/functions.php.html
@@ -56,13 +56,13 @@
16 / 18
CRAP
-
- 97.59% covered (success)
+
+ 97.62% covered (success)
- 97.59%
- 81 / 83
+ 97.62%
+ 82 / 84
@@ -150,7 +150,7 @@
- Taproot\IndieAuth\hashAuthorizationRequestParameters
+ Taproot\IndieAuth\hashAuthorizationRequestParameters
100.00% covered (success)
@@ -159,7 +159,7 @@
100.00%
1 / 1
- 3
+ 6
100.00% covered (success)
@@ -167,11 +167,11 @@
100.00%
- 9 / 9
+ 10 / 10
- Taproot\IndieAuth\isIndieAuthAuthorizationCodeRedeemingRequest
+ Taproot\IndieAuth\isIndieAuthAuthorizationCodeRedeemingRequest
100.00% covered (success)
@@ -192,7 +192,7 @@
- Taproot\IndieAuth\isIndieAuthAuthorizationRequest
+ Taproot\IndieAuth\isIndieAuthAuthorizationRequest
100.00% covered (success)
@@ -213,7 +213,7 @@
- Taproot\IndieAuth\isAuthorizationApprovalRequest
+ Taproot\IndieAuth\isAuthorizationApprovalRequest
100.00% covered (success)
@@ -234,7 +234,7 @@
- Taproot\IndieAuth\buildQueryString
+ Taproot\IndieAuth\buildQueryString
100.00% covered (success)
@@ -255,7 +255,7 @@
- Taproot\IndieAuth\urlComponentsMatch
+ Taproot\IndieAuth\urlComponentsMatch
0.00% covered (danger)
@@ -276,7 +276,7 @@
- Taproot\IndieAuth\appendQueryParams
+ Taproot\IndieAuth\appendQueryParams
100.00% covered (success)
@@ -297,7 +297,7 @@
- Taproot\IndieAuth\trySetLogger
+ Taproot\IndieAuth\trySetLogger
100.00% covered (success)
@@ -318,7 +318,7 @@
- Taproot\IndieAuth\renderTemplate
+ Taproot\IndieAuth\renderTemplate
0.00% covered (danger)
@@ -339,7 +339,7 @@
- Taproot\IndieAuth\isClientIdentifier
+ Taproot\IndieAuth\isClientIdentifier
100.00% covered (success)
@@ -360,7 +360,7 @@
- Taproot\IndieAuth\isProfileUrl
+ Taproot\IndieAuth\isProfileUrl
100.00% covered (success)
@@ -381,7 +381,7 @@
- Taproot\IndieAuth\isValidState
+ Taproot\IndieAuth\isValidState
100.00% covered (success)
@@ -402,7 +402,7 @@
- Taproot\IndieAuth\isValidCodeChallenge
+ Taproot\IndieAuth\isValidCodeChallenge
100.00% covered (success)
@@ -423,7 +423,7 @@
- Taproot\IndieAuth\isValidScope
+ Taproot\IndieAuth\isValidScope
100.00% covered (success)
@@ -460,8 +460,8 @@
9
10 // From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
11 function generateRandomString(int $numBytes): string {
- 12 if (function_exists('random_bytes')) {
- 13 $bytes = random_bytes($numBytes);
+ 12 if (function_exists('random_bytes')) {
+ 13 $bytes = random_bytes($numBytes);
14 // We can’t easily test the following code.
15 // @codeCoverageIgnoreStart
16 } elseif (function_exists('openssl_random_pseudo_bytes')){
@@ -473,206 +473,210 @@
22 }
23 // @codeCoverageIgnoreEnd
24 }
- 25 return bin2hex($bytes);
+ 25 return bin2hex($bytes);
26 }
27
28 function generateRandomPrintableAsciiString(int $length): string {
- 29 $chars = [];
- 30 while (count($chars) < $length) {
+ 29 $chars = [];
+ 30 while (count($chars) < $length) {
31 // 0x21 to 0x7E is the entire printable ASCII range, not including space (0x20).
- 32 $chars[] = chr(random_int(0x21, 0x7E));
+ 32 $chars[] = chr(random_int(0x21, 0x7E));
33 }
- 34 return join('', $chars);
+ 34 return join('', $chars);
35 }
36
37 function generatePKCECodeChallenge(string $plaintext): string {
- 38 return base64_urlencode(hash('sha256', $plaintext, true));
+ 38 return base64_urlencode(hash('sha256', $plaintext, true));
39 }
40
41 function base64_urlencode(string $string): string {
- 42 return rtrim(strtr(base64_encode($string), '+/', '-_'), '=');
+ 42 return rtrim(strtr(base64_encode($string), '+/', '-_'), '=');
43 }
44
- 45 function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null): ?string {
- 46 $hashedParameters = $hashedParameters ?? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'];
- 47 $algo = $algo ?? 'sha256';
- 48
- 49 $queryParams = $request->getQueryParams();
- 50 $data = '';
- 51 foreach ($hashedParameters as $key) {
- 52 if (!isset($queryParams[$key])) {
- 53 return null;
- 54 }
- 55 $data .= $queryParams[$key];
- 56 }
- 57 return hash_hmac($algo, $data, $secret);
- 58 }
- 59
- 60 function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request): bool {
- 61 return strtolower($request->getMethod()) == 'post'
- 62 && array_key_exists('grant_type', $request->getParsedBody() ?? [])
- 63 && $request->getParsedBody()['grant_type'] == 'authorization_code';
- 64 }
- 65
- 66 function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, array $permittedMethods=['get']): bool {
- 67 return in_array(strtolower($request->getMethod()), array_map('strtolower', $permittedMethods))
- 68 && array_key_exists('response_type', $request->getQueryParams())
- 69 && $request->getQueryParams()['response_type'] == 'code';
- 70 }
- 71
- 72 function isAuthorizationApprovalRequest(ServerRequestInterface $request): bool {
- 73 return strtolower($request->getMethod()) == 'post'
- 74 && array_key_exists('taproot_indieauth_action', $request->getParsedBody() ?? [])
- 75 && $request->getParsedBody()[Server::APPROVE_ACTION_KEY] == Server::APPROVE_ACTION_VALUE;
- 76 }
- 77
- 78 function buildQueryString(array $parameters): string {
- 79 $qs = [];
- 80 foreach ($parameters as $k => $v) {
- 81 $qs[] = urlencode($k) . '=' . urlencode($v);
- 82 }
- 83 return join('&', $qs);
- 84 }
- 85
- 86 function urlComponentsMatch(string $url1, string $url2, ?array $components=null): bool {
- 87 $validComponents = [PHP_URL_HOST, PHP_URL_PASS, PHP_URL_PATH, PHP_URL_PORT, PHP_URL_USER, PHP_URL_QUERY, PHP_URL_SCHEME, PHP_URL_FRAGMENT];
- 88 $components = $components ?? $validComponents;
+ 45 function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null, bool $requirePkce=true): ?string {
+ 46 $queryParams = $request->getQueryParams();
+ 47
+ 48 if (is_null($hashedParameters)) {
+ 49 $hashedParameters = ($requirePkce or isset($queryParams['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'] : ['client_id', 'redirect_uri'];
+ 50 }
+ 51
+ 52 $algo = $algo ?? 'sha256';
+ 53
+ 54 $data = '';
+ 55 foreach ($hashedParameters as $key) {
+ 56 if (!isset($queryParams[$key])) {
+ 57 return null;
+ 58 }
+ 59 $data .= $queryParams[$key];
+ 60 }
+ 61 return hash_hmac($algo, $data, $secret);
+ 62 }
+ 63
+ 64 function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request): bool {
+ 65 return strtolower($request->getMethod()) == 'post'
+ 66 && array_key_exists('grant_type', $request->getParsedBody() ?? [])
+ 67 && $request->getParsedBody()['grant_type'] == 'authorization_code';
+ 68 }
+ 69
+ 70 function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, array $permittedMethods=['get']): bool {
+ 71 return in_array(strtolower($request->getMethod()), array_map('strtolower', $permittedMethods))
+ 72 && array_key_exists('response_type', $request->getQueryParams())
+ 73 && $request->getQueryParams()['response_type'] == 'code';
+ 74 }
+ 75
+ 76 function isAuthorizationApprovalRequest(ServerRequestInterface $request): bool {
+ 77 return strtolower($request->getMethod()) == 'post'
+ 78 && array_key_exists('taproot_indieauth_action', $request->getParsedBody() ?? [])
+ 79 && $request->getParsedBody()[Server::APPROVE_ACTION_KEY] == Server::APPROVE_ACTION_VALUE;
+ 80 }
+ 81
+ 82 function buildQueryString(array $parameters): string {
+ 83 $qs = [];
+ 84 foreach ($parameters as $k => $v) {
+ 85 $qs[] = urlencode($k) . '=' . urlencode($v);
+ 86 }
+ 87 return join('&', $qs);
+ 88 }
89
- 90 foreach ($components as $cmp) {
- 91 if (!in_array($cmp, $validComponents)) {
- 92 throw new Exception("Invalid parse_url() component passed: $cmp");
- 93 }
- 94
- 95 if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) {
- 96 return false;
+ 90 function urlComponentsMatch(string $url1, string $url2, ?array $components=null): bool {
+ 91 $validComponents = [PHP_URL_HOST, PHP_URL_PASS, PHP_URL_PATH, PHP_URL_PORT, PHP_URL_USER, PHP_URL_QUERY, PHP_URL_SCHEME, PHP_URL_FRAGMENT];
+ 92 $components = $components ?? $validComponents;
+ 93
+ 94 foreach ($components as $cmp) {
+ 95 if (!in_array($cmp, $validComponents)) {
+ 96 throw new Exception("Invalid parse_url() component passed: $cmp");
97 }
- 98 }
- 99
- 100 return true;
- 101 }
- 102
- 103 /**
- 104 * Append Query Parameters
- 105 *
- 106 * Converts `$queryParams` into a query string, then checks `$uri` for an
- 107 * existing query string. Then appends the newly generated query string
- 108 * with either ? or & as appropriate.
- 109 */
- 110 function appendQueryParams(string $uri, array $queryParams): string {
- 111 if (empty($queryParams)) {
- 112 return $uri;
- 113 }
- 114
- 115 $queryString = buildQueryString($queryParams);
- 116 $separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?';
- 117 $uri = rtrim($uri, '?&');
- 118 return "{$uri}{$separator}{$queryString}";
- 119 }
- 120
- 121 /**
- 122 * Try setLogger
- 123 *
- 124 * If `$target` implements `LoggerAwareInterface`, set it’s logger
- 125 * to `$logger`. Returns `$target`.
- 126 *
- 127 * @psalm-suppress MissingReturnType
- 128 * @psalm-suppress MissingParamType
- 129 */
- 130 function trySetLogger($target, LoggerInterface $logger) {
- 131 if ($target instanceof LoggerAwareInterface) {
- 132 $target->setLogger($logger);
- 133 }
- 134 return $target;
- 135 }
- 136
- 137 function renderTemplate(string $template, array $context=[]) {
- 138 $render = function ($__template, $__templateData) {
- 139 $render = function ($template, $data){
- 140 return renderTemplate($template, $data);
- 141 };
- 142 ob_start();
- 143 extract($__templateData);
- 144 unset($__templateData);
- 145 include $__template;
- 146 return ob_get_clean();
- 147 };
- 148 return $render($template, $context);
- 149 }
- 150
- 151 // IndieAuth/OAuth2-related Validation Functions
- 152 // Mostly taken or adapted by https://github.com/Zegnat/php-mindee/ — thanks Zegnat!
- 153 // Code was not licensed at time of writing, permission granted here https://chat.indieweb.org/dev/2021-06-10/1623327498355700
+ 98
+ 99 if (parse_url($url1, $cmp) !== parse_url($url2, $cmp)) {
+ 100 return false;
+ 101 }
+ 102 }
+ 103
+ 104 return true;
+ 105 }
+ 106
+ 107 /**
+ 108 * Append Query Parameters
+ 109 *
+ 110 * Converts `$queryParams` into a query string, then checks `$uri` for an
+ 111 * existing query string. Then appends the newly generated query string
+ 112 * with either ? or & as appropriate.
+ 113 */
+ 114 function appendQueryParams(string $uri, array $queryParams): string {
+ 115 if (empty($queryParams)) {
+ 116 return $uri;
+ 117 }
+ 118
+ 119 $queryString = buildQueryString($queryParams);
+ 120 $separator = parse_url($uri, \PHP_URL_QUERY) ? '&' : '?';
+ 121 $uri = rtrim($uri, '?&');
+ 122 return "{$uri}{$separator}{$queryString}";
+ 123 }
+ 124
+ 125 /**
+ 126 * Try setLogger
+ 127 *
+ 128 * If `$target` implements `LoggerAwareInterface`, set it’s logger
+ 129 * to `$logger`. Returns `$target`.
+ 130 *
+ 131 * @psalm-suppress MissingReturnType
+ 132 * @psalm-suppress MissingParamType
+ 133 */
+ 134 function trySetLogger($target, LoggerInterface $logger) {
+ 135 if ($target instanceof LoggerAwareInterface) {
+ 136 $target->setLogger($logger);
+ 137 }
+ 138 return $target;
+ 139 }
+ 140
+ 141 function renderTemplate(string $template, array $context=[]) {
+ 142 $render = function ($__template, $__templateData) {
+ 143 $render = function ($template, $data){
+ 144 return renderTemplate($template, $data);
+ 145 };
+ 146 ob_start();
+ 147 extract($__templateData);
+ 148 unset($__templateData);
+ 149 include $__template;
+ 150 return ob_get_clean();
+ 151 };
+ 152 return $render($template, $context);
+ 153 }
154
- 155 /**
- 156 * Check if a provided string matches the IndieAuth criteria for a Client Identifier.
- 157 * @see https://indieauth.spec.indieweb.org/#client-identifier
- 158 *
- 159 * @param string $client_id The client ID provided by the OAuth Client
- 160 * @return bool true if the value is allowed by IndieAuth
- 161 */
- 162 function isClientIdentifier(string $client_id): bool {
- 163 return ($url_components = parse_url($client_id)) && // Clients are identified by a URL.
- 164 in_array($url_components['scheme'] ?? '', ['http', 'https']) && // Client identifier URLs MUST have either an https or http scheme,
- 165 0 < strlen($url_components['path'] ?? '') && // MUST contain a path component,
- 166 false === strpos($url_components['path'], '/./') && // MUST NOT contain single-dot
- 167 false === strpos($url_components['path'], '/../') && // or double-dot path segments,
- 168 false === isset($url_components['fragment']) && // MUST NOT contain a fragment component,
- 169 false === isset($url_components['user']) && // MUST NOT contain a username
- 170 false === isset($url_components['pass']) && // or password component,
- 171 (
- 172 false === filter_var($url_components['host'], FILTER_VALIDATE_IP) || // MUST NOT be an IP address
- 173 ($url_components['host'] ?? null) == '127.0.0.1' || // except for 127.0.0.1
- 174 ($url_components['host'] ?? null) == '[::1]' // or [::1]
- 175 )
- 176 ;
- 177 }
- 178
- 179 /**
- 180 * Check if a provided string matches the IndieAuth criteria for a User Profile URL.
- 181 * @see https://indieauth.spec.indieweb.org/#user-profile-url
- 182 *
- 183 * @param string $profile_url The profile URL provided by the IndieAuth Client as me
- 184 * @return bool true if the value is allowed by IndieAuth
- 185 */
- 186 function isProfileUrl(string $profile_url): bool {
- 187 return ($url_components = parse_url($profile_url)) && // Users are identified by a URL.
- 188 in_array($url_components['scheme'] ?? '', ['http', 'https']) && // Profile URLs MUST have either an https or http scheme,
- 189 0 < strlen($url_components['path'] ?? '') && // MUST contain a path component,
- 190 false === strpos($url_components['path'], '/./') && // MUST NOT contain single-dot
- 191 false === strpos($url_components['path'], '/../') && // or double-dot path segments,
- 192 false === isset($url_components['fragment']) && // MUST NOT contain a fragment component,
- 193 false === isset($url_components['user']) && // MUST NOT contain a username
- 194 false === isset($url_components['pass']) && // or password component,
- 195 false === isset($url_components['port']) && // MUST NOT contain a port,
- 196 false === filter_var($url_components['host'], FILTER_VALIDATE_IP) // MUST NOT be an IP address.
- 197 ;
- 198 }
- 199
- 200 /**
- 201 * OAuth 2.0 limits what values are valid for state.
- 202 * We check this first, because if valid, we want to send it along with other errors.
- 203 * @see https://tools.ietf.org/html/rfc6749#appendix-A.5
- 204 */
- 205 function isValidState(string $state): bool {
- 206 return false !== filter_var($state, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[\x20-\x7E]*$/']]);
- 207 }
- 208
- 209 /**
- 210 * IndieAuth requires PKCE. This implementation supports only S256 for hashing.
- 211 *
- 212 * @see https://indieauth.spec.indieweb.org/#authorization-request
- 213 */
- 214 function isValidCodeChallenge(string $challenge): bool {
- 215 return false !== filter_var($challenge, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[A-Za-z0-9_-]+$/']]);
- 216 }
- 217
- 218 /**
- 219 * OAuth 2.0 limits what values are valid for scope.
- 220 * @see https://tools.ietf.org/html/rfc6749#section-3.3
- 221 */
- 222 function isValidScope(string $scope): bool {
- 223 return false !== filter_var($scope, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*)?$/']]);
- 224 }
+ 155 // IndieAuth/OAuth2-related Validation Functions
+ 156 // Mostly taken or adapted by https://github.com/Zegnat/php-mindee/ — thanks Zegnat!
+ 157 // Code was not licensed at time of writing, permission granted here https://chat.indieweb.org/dev/2021-06-10/1623327498355700
+ 158
+ 159 /**
+ 160 * Check if a provided string matches the IndieAuth criteria for a Client Identifier.
+ 161 * @see https://indieauth.spec.indieweb.org/#client-identifier
+ 162 *
+ 163 * @param string $client_id The client ID provided by the OAuth Client
+ 164 * @return bool true if the value is allowed by IndieAuth
+ 165 */
+ 166 function isClientIdentifier(string $client_id): bool {
+ 167 return ($url_components = parse_url($client_id)) && // Clients are identified by a URL.
+ 168 in_array($url_components['scheme'] ?? '', ['http', 'https']) && // Client identifier URLs MUST have either an https or http scheme,
+ 169 0 < strlen($url_components['path'] ?? '') && // MUST contain a path component,
+ 170 false === strpos($url_components['path'], '/./') && // MUST NOT contain single-dot
+ 171 false === strpos($url_components['path'], '/../') && // or double-dot path segments,
+ 172 false === isset($url_components['fragment']) && // MUST NOT contain a fragment component,
+ 173 false === isset($url_components['user']) && // MUST NOT contain a username
+ 174 false === isset($url_components['pass']) && // or password component,
+ 175 (
+ 176 false === filter_var($url_components['host'], FILTER_VALIDATE_IP) || // MUST NOT be an IP address
+ 177 ($url_components['host'] ?? null) == '127.0.0.1' || // except for 127.0.0.1
+ 178 ($url_components['host'] ?? null) == '[::1]' // or [::1]
+ 179 )
+ 180 ;
+ 181 }
+ 182
+ 183 /**
+ 184 * Check if a provided string matches the IndieAuth criteria for a User Profile URL.
+ 185 * @see https://indieauth.spec.indieweb.org/#user-profile-url
+ 186 *
+ 187 * @param string $profile_url The profile URL provided by the IndieAuth Client as me
+ 188 * @return bool true if the value is allowed by IndieAuth
+ 189 */
+ 190 function isProfileUrl(string $profile_url): bool {
+ 191 return ($url_components = parse_url($profile_url)) && // Users are identified by a URL.
+ 192 in_array($url_components['scheme'] ?? '', ['http', 'https']) && // Profile URLs MUST have either an https or http scheme,
+ 193 0 < strlen($url_components['path'] ?? '') && // MUST contain a path component,
+ 194 false === strpos($url_components['path'], '/./') && // MUST NOT contain single-dot
+ 195 false === strpos($url_components['path'], '/../') && // or double-dot path segments,
+ 196 false === isset($url_components['fragment']) && // MUST NOT contain a fragment component,
+ 197 false === isset($url_components['user']) && // MUST NOT contain a username
+ 198 false === isset($url_components['pass']) && // or password component,
+ 199 false === isset($url_components['port']) && // MUST NOT contain a port,
+ 200 false === filter_var($url_components['host'], FILTER_VALIDATE_IP) // MUST NOT be an IP address.
+ 201 ;
+ 202 }
+ 203
+ 204 /**
+ 205 * OAuth 2.0 limits what values are valid for state.
+ 206 * We check this first, because if valid, we want to send it along with other errors.
+ 207 * @see https://tools.ietf.org/html/rfc6749#appendix-A.5
+ 208 */
+ 209 function isValidState(string $state): bool {
+ 210 return false !== filter_var($state, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[\x20-\x7E]*$/']]);
+ 211 }
+ 212
+ 213 /**
+ 214 * IndieAuth requires PKCE. This implementation supports only S256 for hashing.
+ 215 *
+ 216 * @see https://indieauth.spec.indieweb.org/#authorization-request
+ 217 */
+ 218 function isValidCodeChallenge(string $challenge): bool {
+ 219 return false !== filter_var($challenge, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[A-Za-z0-9_-]+$/']]);
+ 220 }
+ 221
+ 222 /**
+ 223 * OAuth 2.0 limits what values are valid for scope.
+ 224 * @see https://tools.ietf.org/html/rfc6749#section-3.3
+ 225 */
+ 226 function isValidScope(string $scope): bool {
+ 227 return false !== filter_var($scope, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*)?$/']]);
+ 228 }
@@ -683,7 +687,7 @@
Legend
ExecutedNot ExecutedDead Code
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/coverage/index.html b/docs/coverage/index.html
index 5f2224a..7453dc0 100644
--- a/docs/coverage/index.html
+++ b/docs/coverage/index.html
@@ -44,21 +44,21 @@
Total
-
- 94.62% covered (success)
+
+ 95.48% covered (success)
- 94.62%
- 510 / 539
+ 95.48%
+ 528 / 553
-
- 73.68% covered (warning)
+
+ 75.44% covered (warning)
- 73.68%
- 42 / 57
+ 75.44%
+ 43 / 57
44.44% covered (danger)
@@ -184,21 +184,21 @@
Server.php
-
- 94.98% covered (success)
+
+ 96.69% covered (success)
- 94.98%
- 246 / 259
+ 96.69%
+ 263 / 272
-
- 60.00% covered (warning)
+
+ 80.00% covered (warning)
- 60.00%
- 3 / 5
+ 80.00%
+ 4 / 5
0.00% covered (danger)
@@ -212,13 +212,13 @@
functions.php
-
- 97.59% covered (success)
+
+ 97.62% covered (success)
- 97.59%
- 81 / 83
+ 97.62%
+ 82 / 84
88.89% covered (warning)
@@ -245,7 +245,7 @@
High: 90% to 100%
- Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Wed Jun 16 21:41:10 UTC 2021.
+ Generated by php-code-coverage 9.2.6 using PHP 7.4.19 with Xdebug 3.0.4 and PHPUnit 9.5.5 at Thu Jun 17 22:38:11 UTC 2021.
diff --git a/docs/files/src-functions.html b/docs/files/src-functions.html
index 30eecc4..90080e6 100644
--- a/docs/files/src-functions.html
+++ b/docs/files/src-functions.html
@@ -91,21 +91,21 @@
generateRandomString()
- : mixed
+ : string
generateRandomPrintableAsciiString()
- : mixed
+ : string
generatePKCECodeChallenge()
- : mixed
+ : string
@@ -161,7 +161,7 @@
appendQueryParams()
- : mixed
+ : string
Append Query Parameters
@@ -238,14 +238,14 @@
- generateRandomString(mixed $numBytes) : mixed
+ generateRandomString(int $numBytes) : string
Parameters
-
$numBytes
- : mixed
+ : int
-
@@ -270,7 +270,7 @@
- generateRandomPrintableAsciiString(int $length) : mixed
+ generateRandomPrintableAsciiString(int $length) : string
Parameters
@@ -302,14 +302,14 @@
- generatePKCECodeChallenge(mixed $plaintext) : mixed
+ generatePKCECodeChallenge(string $plaintext) : string
Parameters
-
$plaintext
- : mixed
+ : string
-
@@ -366,7 +366,7 @@
- hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ]) : string|null
+ hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ][, bool $requirePkce = true ]) : string|null
Parameters
@@ -398,6 +398,13 @@
= null
-
+
+ -
+ $requirePkce
+ : bool
+ = true
+ -
+
@@ -413,7 +420,7 @@
@@ -445,7 +452,7 @@
@@ -484,7 +491,7 @@
@@ -516,7 +523,7 @@
@@ -548,7 +555,7 @@
@@ -594,14 +601,14 @@
Append Query Parameters
- appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : mixed
+ appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : string
Converts $queryParams
into a query string, then checks $uri
for an
existing query string. Then appends the newly generated query string
@@ -638,7 +645,7 @@ with either ? or & as appropriate.
Try setLogger
@@ -670,6 +677,30 @@ to $logger
. Returns $
+
+ Tags
+
+
+
+ -
+ psalm-suppress
+
+ -
+
+
MissingReturnType
+
+
+
+ -
+ psalm-suppress
+
+ -
+
+
MissingParamType
+
+
+
+
@@ -681,7 +712,7 @@ to $logger
. Returns $
@@ -720,7 +751,7 @@ to $logger
. Returns $
Check if a provided string matches the IndieAuth criteria for a Client Identifier.
@@ -771,7 +802,7 @@ to $logger
. Returns $
Check if a provided string matches the IndieAuth criteria for a User Profile URL.
@@ -822,7 +853,7 @@ to $logger
. Returns $
OAuth 2.0 limits what values are valid for state.
@@ -872,7 +903,7 @@ to $logger
. Returns $
IndieAuth requires PKCE. This implementation supports only S256 for hashing.
@@ -921,7 +952,7 @@ to $logger
. Returns $
OAuth 2.0 limits what values are valid for scope.
diff --git a/docs/js/searchIndex.js b/docs/js/searchIndex.js
index 6f447d6..89883f7 100644
--- a/docs/js/searchIndex.js
+++ b/docs/js/searchIndex.js
@@ -290,6 +290,11 @@ Search.appendIndex(
"name": "INVALID_REQUEST",
"summary": "",
"url": "classes/Taproot-IndieAuth-IndieAuthException.html#constant_INVALID_REQUEST"
+ }, {
+ "fqsen": "\\Taproot\\IndieAuth\\IndieAuthException\u003A\u003AINVALID_REQUEST_REDIRECT",
+ "name": "INVALID_REQUEST_REDIRECT",
+ "summary": "",
+ "url": "classes/Taproot-IndieAuth-IndieAuthException.html#constant_INVALID_REQUEST_REDIRECT"
}, {
"fqsen": "\\Taproot\\IndieAuth\\IndieAuthException\u003A\u003AEXC_INFO",
"name": "EXC_INFO",
@@ -535,6 +540,11 @@ Search.appendIndex(
"name": "secret",
"summary": "",
"url": "classes/Taproot-IndieAuth-Server.html#property_secret"
+ }, {
+ "fqsen": "\\Taproot\\IndieAuth\\Server\u003A\u003A\u0024requirePkce",
+ "name": "requirePkce",
+ "summary": "",
+ "url": "classes/Taproot-IndieAuth-Server.html#property_requirePkce"
}, {
"fqsen": "\\Taproot\\IndieAuth\\Storage\\FilesystemJsonStorage",
"name": "FilesystemJsonStorage",
diff --git a/docs/namespaces/taproot-indieauth.html b/docs/namespaces/taproot-indieauth.html
index b95d5be..145f8f9 100644
--- a/docs/namespaces/taproot-indieauth.html
+++ b/docs/namespaces/taproot-indieauth.html
@@ -115,21 +115,21 @@
generateRandomString()
- : mixed
+ : string
generateRandomPrintableAsciiString()
- : mixed
+ : string
generatePKCECodeChallenge()
- : mixed
+ : string
@@ -185,7 +185,7 @@
appendQueryParams()
- : mixed
+ : string
Append Query Parameters
@@ -261,14 +261,14 @@
- generateRandomString(mixed $numBytes) : mixed
+ generateRandomString(int $numBytes) : string
Parameters
-
$numBytes
- : mixed
+ : int
-
@@ -293,7 +293,7 @@
- generateRandomPrintableAsciiString(int $length) : mixed
+ generateRandomPrintableAsciiString(int $length) : string
Parameters
@@ -325,14 +325,14 @@
- generatePKCECodeChallenge(mixed $plaintext) : mixed
+ generatePKCECodeChallenge(string $plaintext) : string
Parameters
-
$plaintext
- : mixed
+ : string
-
@@ -389,7 +389,7 @@
- hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ]) : string|null
+ hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ][, bool $requirePkce = true ]) : string|null
Parameters
@@ -421,6 +421,13 @@
= null
+
+
+ $requirePkce
+ : bool
+ = true
+
+
@@ -436,7 +443,7 @@
@@ -468,7 +475,7 @@
@@ -507,7 +514,7 @@
@@ -539,7 +546,7 @@
@@ -571,7 +578,7 @@
@@ -617,14 +624,14 @@
Append Query Parameters
- appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : mixed
+ appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : string
Converts $queryParams
into a query string, then checks $uri
for an
existing query string. Then appends the newly generated query string
@@ -661,7 +668,7 @@ with either ? or & as appropriate.
Try setLogger
@@ -693,6 +700,30 @@ to $logger
. Returns $
+
+ Tags
+
+
+
+ -
+ psalm-suppress
+
+ -
+
+
MissingReturnType
+
+
+
+ -
+ psalm-suppress
+
+ -
+
+
MissingParamType
+
+
+
+
@@ -704,7 +735,7 @@ to $logger
. Returns $
@@ -743,7 +774,7 @@ to $logger
. Returns $
Check if a provided string matches the IndieAuth criteria for a Client Identifier.
@@ -794,7 +825,7 @@ to $logger
. Returns $
Check if a provided string matches the IndieAuth criteria for a User Profile URL.
@@ -845,7 +876,7 @@ to $logger
. Returns $
OAuth 2.0 limits what values are valid for state.
@@ -895,7 +926,7 @@ to $logger
. Returns $
IndieAuth requires PKCE. This implementation supports only S256 for hashing.
@@ -944,7 +975,7 @@ to $logger
. Returns $
OAuth 2.0 limits what values are valid for scope.
diff --git a/docs/packages/default.html b/docs/packages/default.html
index 5c2e1ad..c84f384 100644
--- a/docs/packages/default.html
+++ b/docs/packages/default.html
@@ -122,21 +122,21 @@
generateRandomString()
- : mixed
+ : string
generateRandomPrintableAsciiString()
- : mixed
+ : string
generatePKCECodeChallenge()
- : mixed
+ : string
@@ -192,7 +192,7 @@
appendQueryParams()
- : mixed
+ : string
Append Query Parameters
@@ -268,14 +268,14 @@
- generateRandomString(mixed $numBytes) : mixed
+ generateRandomString(int $numBytes) : string
Parameters
-
$numBytes
- : mixed
+ : int
-
@@ -300,7 +300,7 @@
- generateRandomPrintableAsciiString(int $length) : mixed
+ generateRandomPrintableAsciiString(int $length) : string
Parameters
@@ -332,14 +332,14 @@
- generatePKCECodeChallenge(mixed $plaintext) : mixed
+ generatePKCECodeChallenge(string $plaintext) : string
Parameters
-
$plaintext
- : mixed
+ : string
-
@@ -396,7 +396,7 @@
- hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ]) : string|null
+ hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret[, string|null $algo = null ][, array<string|int, mixed>|null $hashedParameters = null ][, bool $requirePkce = true ]) : string|null
Parameters
@@ -428,6 +428,13 @@
= null
+
+
+ $requirePkce
+ : bool
+ = true
+
+
@@ -443,7 +450,7 @@
@@ -475,7 +482,7 @@
@@ -514,7 +521,7 @@
@@ -546,7 +553,7 @@
@@ -578,7 +585,7 @@
@@ -624,14 +631,14 @@
Append Query Parameters
- appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : mixed
+ appendQueryParams(string $uri, array<string|int, mixed> $queryParams) : string
Converts $queryParams
into a query string, then checks $uri
for an
existing query string. Then appends the newly generated query string
@@ -668,7 +675,7 @@ with either ? or & as appropriate.
Try setLogger
@@ -700,6 +707,30 @@ to $logger
. Returns $
+
+ Tags
+
+
+
+ -
+ psalm-suppress
+
+ -
+
+
MissingReturnType
+
+
+
+ -
+ psalm-suppress
+
+ -
+
+
MissingParamType
+
+
+
+
@@ -711,7 +742,7 @@ to $logger
. Returns $
@@ -750,7 +781,7 @@ to $logger
. Returns $
Check if a provided string matches the IndieAuth criteria for a Client Identifier.
@@ -801,7 +832,7 @@ to $logger
. Returns $
Check if a provided string matches the IndieAuth criteria for a User Profile URL.
@@ -852,7 +883,7 @@ to $logger
. Returns $
OAuth 2.0 limits what values are valid for state.
@@ -902,7 +933,7 @@ to $logger
. Returns $
IndieAuth requires PKCE. This implementation supports only S256 for hashing.
@@ -951,7 +982,7 @@ to $logger
. Returns $
OAuth 2.0 limits what values are valid for scope.
diff --git a/src/IndieAuthException.php b/src/IndieAuthException.php
index 7c4041c..e09d8a4 100644
--- a/src/IndieAuthException.php
+++ b/src/IndieAuthException.php
@@ -21,6 +21,7 @@ class IndieAuthException extends Exception {
const INVALID_SCOPE = 11;
const INVALID_GRANT = 12;
const INVALID_REQUEST = 13;
+ const INVALID_REQUEST_REDIRECT = 14;
const EXC_INFO = [
self::INTERNAL_ERROR => ['statusCode' => 500, 'name' => 'Internal Server Error', 'explanation' => 'An internal server error occurred.'],
@@ -38,6 +39,7 @@ class IndieAuthException extends Exception {
self::INVALID_SCOPE => ['statusCode' => 302, 'name' => 'Invalid scope Parameter', 'error' => 'invalid_request'],
self::INVALID_GRANT => ['statusCode' => 400, 'name' => 'The provided credentials were not valid.', 'error' => 'invalid_grant'],
self::INVALID_REQUEST => ['statusCode' => 400, 'name' => 'Invalid Request', 'error' => 'invalid_request'],
+ self::INVALID_REQUEST_REDIRECT => ['statusCode' => 302, 'name' => 'Invalid Request', 'error' => 'invalid_request'],
];
protected ServerRequestInterface $request;
diff --git a/src/Server.php b/src/Server.php
index 3efa42f..f2ec744 100644
--- a/src/Server.php
+++ b/src/Server.php
@@ -121,6 +121,8 @@ class Server {
protected string $secret;
+ protected bool $requirePkce;
+
/**
* Constructor
*
@@ -197,8 +199,11 @@ class Server {
'httpGetWithEffectiveUrl' => null,
'authorizationForm' => new DefaultAuthorizationForm(),
'exceptionTemplatePath' => __DIR__ . '/../templates/default_exception_response.html.php',
+ 'requirePKCE' => true,
], $config);
+ $this->requirePkce = $config['requirePKCE'];
+
if (!is_string($config['exceptionTemplatePath'])) {
throw new BadMethodCallException("\$config['exceptionTemplatePath'] must be a string (path).");
}
@@ -348,7 +353,7 @@ class Server {
// on the auth code before it gets exchanged.
$tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
// Verify that all required parameters are included.
- $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier'];
+ $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri'];
$missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
});
@@ -364,13 +369,22 @@ class Server {
throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
}
- // Check that the supplied code_verifier hashes to the stored code_challenge
- // TODO: support method = plain as well as S256.
- if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
- $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
+ // If the auth code was requested with no code_challenge, but the exchange request provides a
+ // code_verifier, return an error.
+ if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) {
+ $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge.");
throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
}
+ if ($this->requirePkce or !empty($authCode['code_challenge'])) {
+ // Check that the supplied code_verifier hashes to the stored code_challenge
+ // TODO: support method = plain as well as S256.
+ if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
+ $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
+ throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ }
+ }
+
// Check that this token either grants at most the profile scope.
$requestedScopes = array_filter(explode(' ', $authCode['scope'] ?? ''));
if (!empty($requestedScopes) && $requestedScopes != ['profile']) {
@@ -441,7 +455,7 @@ class Server {
// How most errors are handled depends on whether or not the request has a valid redirect_uri. In
// order to know that, we need to also validate, fetch and parse the client_id.
// If the request lacks a hash, or if the provided hash was invalid, perform the validation.
- $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret);
+ $currentRequestHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce);
if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($currentRequestHash) or !hash_equals($currentRequestHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
// All we need to know at this stage is whether the redirect_uri is valid. If it
@@ -502,22 +516,29 @@ class Server {
$this->logger->warning("The state provided in an authorization request was not valid.", $queryParams);
throw IndieAuthException::create(IndieAuthException::INVALID_STATE, $request);
}
-
- // Validate code_challenge parameter.
- if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) {
- $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams);
- throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
- }
-
- if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) {
- $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams);
- throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
- }
-
// From now on, any redirect error responses should include the state parameter.
// This is handled automatically in `handleException()` and is only noted here
// for reference.
+ // If either PKCE parameter is present, validate both.
+ if (isset($queryParams['code_challenge']) or isset($queryParams['code_challenge_method'])) {
+ if (!isset($queryParams['code_challenge']) or !isValidCodeChallenge($queryParams['code_challenge'])) {
+ $this->logger->warning("The code_challenge provided in an authorization request was not valid.", $queryParams);
+ throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
+ }
+
+ if (!isset($queryParams['code_challenge_method']) or !in_array($queryParams['code_challenge_method'], ['S256', 'plain'])) {
+ $this->logger->error("The code_challenge_method parameter was missing or invalid.", $queryParams);
+ throw IndieAuthException::create(IndieAuthException::INVALID_CODE_CHALLENGE, $request);
+ }
+ } else {
+ // If neither PKCE parameter is defined, and PKCE is required, throw an error. Otherwise, proceed.
+ if ($this->requirePkce) {
+ $this->logger->warning("PKCE is required, and both code_challenge and code_challenge_method were missing.");
+ throw IndieAuthException::create(IndieAuthException::INVALID_REQUEST_REDIRECT, $request);
+ }
+ }
+
// Validate the scope parameter, if provided.
if (array_key_exists('scope', $queryParams) && !isValidScope($queryParams['scope'])) {
$this->logger->warning("The scope provided in an authorization request was not valid.", $queryParams);
@@ -535,8 +556,9 @@ class Server {
// Build a URL containing the indieauth authorization request parameters, hashing them
// to protect them from being changed.
- // Make a hash of the protected indieauth-specific parameters.
- $hash = hashAuthorizationRequestParameters($request, $this->secret);
+ // Make a hash of the protected indieauth-specific parameters. If PKCE is in use, include
+ // the PKCE parameters in the hash. Otherwise, leave them out.
+ $hash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce);
// Operate on a copy of $queryParams, otherwise requests will always have a valid hash!
$redirectQueryParams = $queryParams;
$redirectQueryParams[self::HASH_QUERY_STRING_KEY] = $hash;
@@ -566,7 +588,7 @@ class Server {
throw IndieAuthException::create(IndieAuthException::AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH, $request);
}
- $expectedHash = hashAuthorizationRequestParameters($request, $this->secret);
+ $expectedHash = hashAuthorizationRequestParameters($request, $this->secret, null, null, $this->requirePkce);
if (!isset($queryParams[self::HASH_QUERY_STRING_KEY]) or is_null($expectedHash) or !hash_equals($expectedHash, $queryParams[self::HASH_QUERY_STRING_KEY])) {
$this->logger->warning("The hash provided in the URL was invalid!", [
'expected' => $expectedHash,
@@ -580,8 +602,8 @@ class Server {
'client_id' => $queryParams['client_id'],
'redirect_uri' => $queryParams['redirect_uri'],
'state' => $queryParams['state'],
- 'code_challenge' => $queryParams['code_challenge'],
- 'code_challenge_method' => $queryParams['code_challenge_method'],
+ 'code_challenge' => $queryParams['code_challenge'] ?? null,
+ 'code_challenge_method' => $queryParams['code_challenge_method'] ?? null,
'requested_scope' => $queryParams['scope'] ?? '',
]);
@@ -710,7 +732,7 @@ class Server {
// on the auth code before it gets exchanged.
$tokenData = $this->tokenStorage->exchangeAuthCodeForAccessToken($bodyParams['code'], function (array $authCode) use ($request, $bodyParams) {
// Verify that all required parameters are included.
- $requiredParameters = ['client_id', 'redirect_uri', 'code_verifier'];
+ $requiredParameters = ($this->requirePkce or !empty($authCode['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_verifier'] : ['client_id', 'redirect_uri'];
$missingRequiredParameters = array_filter($requiredParameters, function ($p) use ($bodyParams) {
return !array_key_exists($p, $bodyParams) || empty($bodyParams[$p]);
});
@@ -726,13 +748,22 @@ class Server {
throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
}
- // Check that the supplied code_verifier hashes to the stored code_challenge
- // TODO: support method = plain as well as S256.
- if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
- $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
+ // If the auth code was requested with no code_challenge, but the exchange request provides a
+ // code_verifier, return an error.
+ if (!empty($bodyParams['code_verifier']) && empty($authCode['code_challenge'])) {
+ $this->logger->error("A code_verifier was provided when trying to exchange an auth code requested without a code_challenge.");
throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
}
+ if ($this->requirePkce or !empty($authCode['code_challenge'])) {
+ // Check that the supplied code_verifier hashes to the stored code_challenge
+ // TODO: support method = plain as well as S256.
+ if (!hash_equals($authCode['code_challenge'], generatePKCECodeChallenge($bodyParams['code_verifier']))) {
+ $this->logger->error("The provided code_verifier did not hash to the stored code_challenge");
+ throw IndieAuthException::create(IndieAuthException::INVALID_GRANT, $request);
+ }
+ }
+
// Check that scope is not empty.
if (empty($authCode['scope'])) {
$this->logger->error("An exchange request for a token with an empty scope was sent to the token endpoint.");
diff --git a/src/functions.php b/src/functions.php
index b0d4e0b..99ae0dc 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -42,11 +42,15 @@ function base64_urlencode(string $string): string {
return rtrim(strtr(base64_encode($string), '+/', '-_'), '=');
}
-function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null): ?string {
- $hashedParameters = $hashedParameters ?? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'];
+function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null, bool $requirePkce=true): ?string {
+ $queryParams = $request->getQueryParams();
+
+ if (is_null($hashedParameters)) {
+ $hashedParameters = ($requirePkce or isset($queryParams['code_challenge'])) ? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'] : ['client_id', 'redirect_uri'];
+ }
+
$algo = $algo ?? 'sha256';
- $queryParams = $request->getQueryParams();
$data = '';
foreach ($hashedParameters as $key) {
if (!isset($queryParams[$key])) {
diff --git a/tests/ServerTest.php b/tests/ServerTest.php
index bbbf0e6..ccd0633 100644
--- a/tests/ServerTest.php
+++ b/tests/ServerTest.php
@@ -168,6 +168,18 @@ class ServerTest extends TestCase {
}
}
}
+
+ public function testRequestsMissingBothPkceParametersReturnsError() {
+ $s = $this->getDefaultServer();
+ $req = $this->getIARequest();
+ $qp = $req->getQueryParams();
+ unset($qp['code_challenge']);
+ unset($qp['code_challenge_method']);
+ $req = $req->withQueryParams($qp);
+
+ $res = $s->handleAuthorizationEndpointRequest($req);
+ $this->assertEquals(302, $res->getStatusCode());
+ }
public function testAuthorizationRequestWithInvalidClientIdOrRedirectUriShowsErrorToUser() {
$testCases = [
@@ -305,7 +317,7 @@ class ServerTest extends TestCase {
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
- 'httpGetWithEffectiveUrl' => function ($url): array {
+ 'httpGetWithEffectiveUrl' => function (string $url): array {
// An empty response suffices for this test.
return [
new Response(200, ['content-type' => 'text/html'], '' ),
@@ -388,7 +400,7 @@ EOT
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
- 'httpGetWithEffectiveUrl' => function ($url): array {
+ 'httpGetWithEffectiveUrl' => function (string $url): array {
return [
new Response(200, [
'content-type' => 'text/html',
@@ -417,7 +429,7 @@ EOT
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction): array {
return ['me' => 'https://me.example.com'];
},
- 'httpGetWithEffectiveUrl' => function ($url): array {
+ 'httpGetWithEffectiveUrl' => function (string $url): array {
return [
new Response(200, ['content-type' => 'text/html'],
''
@@ -447,7 +459,7 @@ EOT
Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request, string $formAction) {
return ['me' => 'https://me.example.com'];
},
- 'httpGetWithEffectiveUrl' => function ($url) use ($correctHAppPhoto, $correctHAppName, $correctHAppUrl) {
+ 'httpGetWithEffectiveUrl' => function (string $url) use ($correctHAppPhoto, $correctHAppName, $correctHAppUrl): array {
return [
new Response(200, ['content-type' => 'text/html'],
<<getDefaultServer();
$req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([
@@ -620,6 +632,34 @@ EOT
$this->assertEquals('invalid_request', $tokenEndpointJson['error']);
}
+ public function testExchangeFlowsReturnErrorsIfParametersAreMissing() {
+ $s = $this->getDefaultServer();
+
+ foreach ([
+ [$s, 'handleAuthorizationEndpointRequest'],
+ [$s, 'handleTokenEndpointRequest']
+ ] as $exchangeEndpoint) {
+ $authCode = $s->getTokenStorage()->createAuthCode([
+ 'client_id' => 'https://client.example.com/',
+ 'redirect_uri' => 'https://client.example.com/auth',
+ 'code_challenge' => generatePKCECodeChallenge(generateRandomString(32)),
+ 'state' => '12345',
+ 'code_challenge_method' => 'S256',
+ 'scope' => 'create update'
+ ]);
+
+ $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode
+ ]);
+
+ $res = $exchangeEndpoint($req);
+ $this->assertEquals(400, $res->getStatusCode());
+ $resJson = json_decode((string) $res->getBody(), true);
+ $this->assertEquals('invalid_request', $resJson['error']);
+ }
+ }
+
public function testExchangeFlowsReturnErrorOnInvalidParameters() {
$s = $this->getDefaultServer();
$storage = new FilesystemJsonStorage(TOKEN_STORAGE_PATH, SERVER_SECRET);
@@ -859,6 +899,146 @@ EOT
$this->assertEquals($responseBody, (string) $res->getBody());
}
+
+ /**
+ * Test Backwards-compatibility
+ */
+
+ public function testBackCompatRequestsWithoutPkceWorkCorrectlyWithBothExchangeFlows() {
+ $s = $this->getDefaultServer([
+ Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request) {
+ return ['me' => 'https://me.example.com/'];
+ },
+ 'httpGetWithEffectiveUrl' => function ($url): array {
+ return [new Response(200, ['content-type' => 'text/html'], ''), $url ];
+ },
+ 'requirePKCE' => false
+ ]);
+
+ foreach ([
+ 'Auth Endpoint Exchange' => [$s, 'handleAuthorizationEndpointRequest'],
+ 'Token Endpoint Exchange' => [$s, 'handleTokenEndpointRequest']
+ ] as $testCase => $handleExchangeRequestEndpoint) {
+ // Clean up the test environment.
+ $this->setUp();
+ // Build a valid, hashed request without either PKCE parameter. This takes some faffing around
+ // due to the supposedly elegant immutable Request objects.
+ $req = $this->getApprovalRequest(true, true, null, [
+ 'taproot_indieauth_server_scope[]' => ['profile']
+ ]);
+ $params = $req->getQueryParams();
+ unset($params['code_challenge']);
+ unset($params['code_challenge_method']);
+ $req = $req->withQueryParams($params);
+ $hash = hashAuthorizationRequestParameters($req, SERVER_SECRET, null, null, false);
+ $params[Server::HASH_QUERY_STRING_KEY] = $hash;
+ $req = $req->withQueryParams($params);
+
+ $res = $s->handleAuthorizationEndpointRequest($req);
+
+ $this->assertEquals(302, $res->getStatusCode(), $testCase);
+ parse_str(parse_url($res->getHeaderLine('location'), PHP_URL_QUERY), $redirectQueryParams);
+ $this->assertArrayNotHasKey('error', $redirectQueryParams, "$testCase");
+ $this->assertNotNull($redirectQueryParams['code'], $testCase);
+
+ // Now check that the token exchange works.
+ // Build
+ $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([
+ 'grant_type' => 'authorization_code',
+ 'code' => $redirectQueryParams['code'],
+ 'client_id' => $params['client_id'],
+ 'redirect_uri' => $params['redirect_uri']
+ ]);
+
+ $res = $handleExchangeRequestEndpoint($req);
+ $resJson = json_decode((string) $res->getBody(), true);
+ $this->assertEquals(200, $res->getStatusCode(), "$testCase");
+ $this->assertIsArray($resJson, $testCase);
+ $this->assertArrayNotHasKey('error', $resJson, $testCase);
+ $this->assertEquals('https://me.example.com/', $resJson['me'], $testCase);
+ if ($testCase == 'Token Endpoint Exchange') {
+ $this->assertArrayHasKey('access_token', $resJson, $testCase);
+ }
+ }
+ }
+
+ public function testBackCompatNonPkceRequestMustLackBothPkceParameters() {
+ $s = $this->getDefaultServer([
+ Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request) {
+ return ['me' => 'https://me.example.com/'];
+ },
+ 'httpGetWithEffectiveUrl' => function ($url): array {
+ return [new Response(200, ['content-type' => 'text/html'], ''), $url ];
+ },
+ 'requirePKCE' => false
+ ]);
+
+ // Build a valid, hashed request without either PKCE parameter. This takes some faffing around
+ // due to the supposedly elegant immutable Request objects.
+ $req = $this->getIARequest();
+ $params = $req->getQueryParams();
+ unset($params['code_challenge']);
+ $req = $req->withQueryParams($params);
+
+ $res = $s->handleAuthorizationEndpointRequest($req);
+
+ $this->assertEquals(302, $res->getStatusCode());
+ parse_str(parse_url($res->getHeaderLine('location'), PHP_URL_QUERY), $redirectQueryParams);
+ $this->assertEquals('invalid_request', $redirectQueryParams['error']);
+ }
+
+ public function testBackCompatAuthCodeWithoutPkceCannotBeExchangedWithCodeVerifierBothExchangeEndpoints() {
+ $s = $this->getDefaultServer([
+ Server::HANDLE_AUTHENTICATION_REQUEST => function (ServerRequestInterface $request) {
+ return ['me' => 'https://me.example.com/'];
+ },
+ 'httpGetWithEffectiveUrl' => function ($url): array {
+ return [new Response(200, ['content-type' => 'text/html'], ''), $url ];
+ },
+ 'requirePKCE' => false
+ ]);
+
+ foreach ([
+ 'Auth Endpoint Exchange' => [$s, 'handleAuthorizationEndpointRequest'],
+ 'Token Endpoint Exchange' => [$s, 'handleTokenEndpointRequest']
+ ] as $testCase => $handleExchangeRequestEndpoint) {
+ // Clean up the test environment.
+ $this->setUp();
+ $storage = new FilesystemJsonStorage(TOKEN_STORAGE_PATH, SERVER_SECRET);
+
+ // Create an auth code without PKCE
+ $authCodeData = [
+ 'client_id' => 'https://client.example.com/',
+ 'redirect_uri' => 'https://client.example.com/auth',
+ 'code_challenge' => null,
+ 'state' => '12345',
+ 'code_challenge_method' => null,
+ 'scope' => 'create update profile',
+ 'me' => 'https://me.example.com/',
+ 'profile' => [
+ 'name' => 'Me'
+ ]
+ ];
+ $authCode = $storage->createAuthCode($authCodeData);
+
+ // Now check that the token exchange works.
+ // Build
+ $req = (new ServerRequest('POST', 'https://example.com'))->withParsedBody([
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode,
+ 'client_id' => $authCodeData['client_id'],
+ 'redirect_uri' => $authCodeData['redirect_uri'],
+ 'code_verifier' => 'There is no valid value for this parameter, its mere presence should trigger an error.'
+ ]);
+
+ $res = $handleExchangeRequestEndpoint($req);
+ $resJson = json_decode((string) $res->getBody(), true);
+ $this->assertEquals(400, $res->getStatusCode(), "$testCase");
+ $this->assertIsArray($resJson, $testCase);
+ $this->assertArrayHasKey('error', $resJson, $testCase);
+ $this->assertEquals('invalid_grant', $resJson['error'], $testCase);
+ }
+ }
}
/**