From c38a1a5eac4772774bdd2ad22d80b00a1d797443 Mon Sep 17 00:00:00 2001 From: Aaron Parecki Date: Sat, 19 Sep 2026 23:22:43 +0000 Subject: [PATCH 1/2] accept the RFC 8414 well-known location as a valid metadata URL for an issuer IndieAuth requires the issuer to be a prefix of the metadata URL. RFC 8414, which the next IndieAuth draft follows, places the document at https://host/.well-known/oauth-authorization-server followed by the issuer's path, so an issuer with a path is never a prefix of it and servers such as IndieKey.id were refused with "Issuer in metadata endpoint is not valid". _isIssuerValid() now accepts either form: the prefix rule as before, or the metadata URL equal to one of the issuer's RFC 8414 locations (the oauth-authorization-server and openid-configuration document names). The https, query and fragment checks are unchanged. Client::wellKnownMetadataURLs() builds the locations and is public for clients that want to explain a refusal. --- .phpunit.result.cache | 1 + src/IndieAuth/Client.php | 35 +++++++++++++++-- tests/MetadataTest.php | 84 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 .phpunit.result.cache diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..20e2283 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":[],"times":{"ClientTest::testErrorResponse":0.001,"ClientTest::testValidateState":0,"ClientTest::testValidateStateCase":0,"ClientTest::testValidateStateMissing":0,"ClientTest::testValidateStateMismatch":0,"ClientTest::testValidateIssuer":0,"ClientTest::testValidateIssuerNormalizes":0,"ClientTest::testValidateIssuerMissing":0,"ClientTest::testValidateIssuerMismatch":0,"DiscoveryTest::testExtractEndpointFromHTML":0,"DiscoveryTest::testExtractRelativeEndpointFromHTML":0,"DiscoveryTest::testExtractEmptyStringEndpointFromHTML":0,"DiscoveryTest::testExtractEndpointFromHeaders":0,"DiscoveryTest::testExtractRelativeEndpointFromHeaders":0,"DiscoveryTest::testExtractEmptyStringEndpointFromHeaders":0,"MetadataTest::testSetAndGetMetadata":0,"MetadataTest::testSetMetadataInvalid":0,"MetadataTest::testDiscoverFromMetadata":0,"MetadataTest::testIsIssuerValid":0,"MetadataTest::testDiscoverIssuer":0,"MetadataTest::testDiscoverIssuerMissing":0,"MetadataTest::testDiscoverIssuerNotAPrefix":0,"MetadataTest::testDiscoverRevocationEndpoint":0,"MetadataTest::testDiscoverIntrospectionEndpoint":0,"MetadataTest::testDiscoverUserinfoEndpoint":0,"NormalizeTest::testBareDomain":0,"NormalizeTest::testNoSchemeWithPath":0,"NormalizeTest::testNoSlash":0,"NormalizeTest::testRejectsInvalidScheme":0,"NormalizeTest::testAllowsQueryString":0,"NormalizeTest::testRejectsFragment":0,"ScopeTest::testFindsProfileScopes":0,"ScopeTest::testWorksWithEmptyScope":0,"ScopeTest::testAcceptsFalseInput":0,"ScopeTest::testExcludesOneScope":0,"ScopeTest::testIncludesOneScope":0,"MetadataTest::testWellKnownMetadataURLs":0,"MetadataTest::testDiscoverIssuerAtWellKnownLocation":0}} \ No newline at end of file diff --git a/src/IndieAuth/Client.php b/src/IndieAuth/Client.php index 5945faf..9afcdb0 100644 --- a/src/IndieAuth/Client.php +++ b/src/IndieAuth/Client.php @@ -236,11 +236,40 @@ private static function _isIssuerValid($issuer, $metadata_endpoint) { $metadata_endpoint = self::normalizeMeURL($metadata_endpoint); - if (strpos($metadata_endpoint, $issuer) !== 0) { - return false; + // The issuer is a prefix of the metadata URL (IndieAuth), or the metadata + // URL is the RFC 8414 well-known location for the issuer, which inserts + // /.well-known/oauth-authorization-server (or openid-configuration) + // between the host and the issuer's path. The next IndieAuth draft uses + // the RFC 8414 form; servers such as IndieKey.id already do. + if (strpos($metadata_endpoint, $issuer) === 0) { + return true; } - return true; + return in_array(rtrim($metadata_endpoint, '/'), self::wellKnownMetadataURLs($issuer), true); + } + + /** + * The RFC 8414 (section 3) metadata locations for an issuer identifier: + * https://host/.well-known/oauth-authorization-server followed by the + * issuer's path, and the openid-configuration equivalent. Without a + * trailing slash, for comparison. + * + * @param string $issuer a normalized issuer URL + * @return string[] + */ + public static function wellKnownMetadataURLs($issuer) { + $parts = parse_url($issuer); + if(!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { + return []; + } + + $base = strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : ''); + $path = rtrim($parts['path'] ?? '', '/'); + + return [ + $base . '/.well-known/oauth-authorization-server' . $path, + $base . '/.well-known/openid-configuration' . $path, + ]; } private static function _fetchHead($url) { diff --git a/tests/MetadataTest.php b/tests/MetadataTest.php index 10182fb..45671f1 100644 --- a/tests/MetadataTest.php +++ b/tests/MetadataTest.php @@ -128,6 +128,74 @@ public function testIsIssuerValid() { ); $this->assertTrue($result); + + # RFC 8414: the well-known path is inserted between the host and the issuer's path + $result = $this->_invokeStaticMethod( + Client::class, + '_isIssuerValid', + ['https://example.com/s/abc/', 'https://example.com/.well-known/oauth-authorization-server/s/abc'] + ); + + $this->assertTrue($result); + + # ... also with the OpenID Connect document name + $result = $this->_invokeStaticMethod( + Client::class, + '_isIssuerValid', + ['https://example.com/s/abc/', 'https://example.com/.well-known/openid-configuration/s/abc'] + ); + + $this->assertTrue($result); + + # ... and for an issuer without a path (also a prefix match) + $result = $this->_invokeStaticMethod( + Client::class, + '_isIssuerValid', + ['https://example.com/', 'https://example.com/.well-known/oauth-authorization-server'] + ); + + $this->assertTrue($result); + + # the well-known location for a different path is not this issuer's + $result = $this->_invokeStaticMethod( + Client::class, + '_isIssuerValid', + ['https://example.com/s/abc/', 'https://example.com/.well-known/oauth-authorization-server/s/xyz'] + ); + + $this->assertFalse($result); + + # nor is the well-known location on another host + $result = $this->_invokeStaticMethod( + Client::class, + '_isIssuerValid', + ['https://example.com/s/abc/', 'https://example.org/.well-known/oauth-authorization-server/s/abc'] + ); + + $this->assertFalse($result); + + # http is still refused, well-known or not + $result = $this->_invokeStaticMethod( + Client::class, + '_isIssuerValid', + ['http://example.com/s/abc/', 'http://example.com/.well-known/oauth-authorization-server/s/abc'] + ); + + $this->assertFalse($result); + } + + public function testWellKnownMetadataURLs() { + $this->assertEquals([ + 'https://example.com/.well-known/oauth-authorization-server/s/abc', + 'https://example.com/.well-known/openid-configuration/s/abc', + ], Client::wellKnownMetadataURLs('https://example.com/s/abc/')); + + $this->assertEquals([ + 'https://example.com:8443/.well-known/oauth-authorization-server', + 'https://example.com:8443/.well-known/openid-configuration', + ], Client::wellKnownMetadataURLs('https://example.com:8443/')); + + $this->assertEquals([], Client::wellKnownMetadataURLs('not a url')); } public function testDiscoverIssuer() @@ -175,6 +243,22 @@ public function testDiscoverIssuerNotAPrefix() $this->assertInstanceOf(ErrorResponse::class, $result); } + /** + * `issuer` may also name the RFC 8414 well-known location the metadata was fetched from + */ + public function testDiscoverIssuerAtWellKnownLocation() + { + $metadata_endpoint = 'https://example.com/.well-known/oauth-authorization-server/s/abc'; + Client::setMetadata($metadata_endpoint, '{"issuer":"https://example.com/s/abc/","authorization_endpoint":"https://example.com/s/abc/auth","token_endpoint":"https://example.com/token"}'); + + $result = $this->_invokeStaticMethod( + Client::class, + 'discoverIssuer', + [$metadata_endpoint] + ); + $this->assertEquals('https://example.com/s/abc/', $result); + } + public function testDiscoverRevocationEndpoint() { Client::setMetadata('https://example.com/', $this->default_metadata); From 4e4b8e6ab1b7caa5d44628bcd3c1d93e58d3432d Mon Sep 17 00:00:00 2001 From: Aaron Parecki Date: Sun, 20 Sep 2026 11:49:31 +0000 Subject: [PATCH 2/2] accept the RFC 8414 well-known location as a valid metadata URL for an issuer IndieAuth requires the issuer to be a prefix of the metadata URL. RFC 8414, which the next IndieAuth draft follows, places the document at https://host/.well-known/oauth-authorization-server followed by the issuer's path, so an issuer with a path is never a prefix of it and servers such as IndieKey.id were refused with "Issuer in metadata endpoint is not valid". _isIssuerValid() now accepts either form: the prefix rule as before, or the metadata URL equal to the issuer's RFC 8414 location. The https, query and fragment checks are unchanged. OpenID Connect discovery documents are not accepted; they follow their own rules and are not IndieAuth metadata. Client::wellKnownMetadataURL() builds the location and is public for clients that want to explain a refusal. --- .gitignore | 2 ++ .phpunit.result.cache | 1 - src/IndieAuth/Client.php | 25 +++++++++++-------------- tests/MetadataTest.php | 20 ++++++-------------- 4 files changed, 19 insertions(+), 29 deletions(-) delete mode 100644 .phpunit.result.cache diff --git a/.gitignore b/.gitignore index 58f394d..6987955 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /vendor/ .DS_Store +.phpunit.result.cache + diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 20e2283..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defects":[],"times":{"ClientTest::testErrorResponse":0.001,"ClientTest::testValidateState":0,"ClientTest::testValidateStateCase":0,"ClientTest::testValidateStateMissing":0,"ClientTest::testValidateStateMismatch":0,"ClientTest::testValidateIssuer":0,"ClientTest::testValidateIssuerNormalizes":0,"ClientTest::testValidateIssuerMissing":0,"ClientTest::testValidateIssuerMismatch":0,"DiscoveryTest::testExtractEndpointFromHTML":0,"DiscoveryTest::testExtractRelativeEndpointFromHTML":0,"DiscoveryTest::testExtractEmptyStringEndpointFromHTML":0,"DiscoveryTest::testExtractEndpointFromHeaders":0,"DiscoveryTest::testExtractRelativeEndpointFromHeaders":0,"DiscoveryTest::testExtractEmptyStringEndpointFromHeaders":0,"MetadataTest::testSetAndGetMetadata":0,"MetadataTest::testSetMetadataInvalid":0,"MetadataTest::testDiscoverFromMetadata":0,"MetadataTest::testIsIssuerValid":0,"MetadataTest::testDiscoverIssuer":0,"MetadataTest::testDiscoverIssuerMissing":0,"MetadataTest::testDiscoverIssuerNotAPrefix":0,"MetadataTest::testDiscoverRevocationEndpoint":0,"MetadataTest::testDiscoverIntrospectionEndpoint":0,"MetadataTest::testDiscoverUserinfoEndpoint":0,"NormalizeTest::testBareDomain":0,"NormalizeTest::testNoSchemeWithPath":0,"NormalizeTest::testNoSlash":0,"NormalizeTest::testRejectsInvalidScheme":0,"NormalizeTest::testAllowsQueryString":0,"NormalizeTest::testRejectsFragment":0,"ScopeTest::testFindsProfileScopes":0,"ScopeTest::testWorksWithEmptyScope":0,"ScopeTest::testAcceptsFalseInput":0,"ScopeTest::testExcludesOneScope":0,"ScopeTest::testIncludesOneScope":0,"MetadataTest::testWellKnownMetadataURLs":0,"MetadataTest::testDiscoverIssuerAtWellKnownLocation":0}} \ No newline at end of file diff --git a/src/IndieAuth/Client.php b/src/IndieAuth/Client.php index 9afcdb0..bb783f3 100644 --- a/src/IndieAuth/Client.php +++ b/src/IndieAuth/Client.php @@ -238,38 +238,35 @@ private static function _isIssuerValid($issuer, $metadata_endpoint) { // The issuer is a prefix of the metadata URL (IndieAuth), or the metadata // URL is the RFC 8414 well-known location for the issuer, which inserts - // /.well-known/oauth-authorization-server (or openid-configuration) - // between the host and the issuer's path. The next IndieAuth draft uses - // the RFC 8414 form; servers such as IndieKey.id already do. + // /.well-known/oauth-authorization-server between the host and the + // issuer's path. The next IndieAuth draft uses the RFC 8414 form; servers + // such as IndieKey.id already do. if (strpos($metadata_endpoint, $issuer) === 0) { return true; } - return in_array(rtrim($metadata_endpoint, '/'), self::wellKnownMetadataURLs($issuer), true); + return rtrim($metadata_endpoint, '/') === self::wellKnownMetadataURL($issuer); } /** - * The RFC 8414 (section 3) metadata locations for an issuer identifier: + * The RFC 8414 (section 3) metadata location for an issuer identifier: * https://host/.well-known/oauth-authorization-server followed by the - * issuer's path, and the openid-configuration equivalent. Without a - * trailing slash, for comparison. + * issuer's path. Without a trailing slash, for comparison. False when the + * issuer is not a URL. * * @param string $issuer a normalized issuer URL - * @return string[] + * @return string|false */ - public static function wellKnownMetadataURLs($issuer) { + public static function wellKnownMetadataURL($issuer) { $parts = parse_url($issuer); if(!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { - return []; + return false; } $base = strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : ''); $path = rtrim($parts['path'] ?? '', '/'); - return [ - $base . '/.well-known/oauth-authorization-server' . $path, - $base . '/.well-known/openid-configuration' . $path, - ]; + return $base . '/.well-known/oauth-authorization-server' . $path; } private static function _fetchHead($url) { diff --git a/tests/MetadataTest.php b/tests/MetadataTest.php index 45671f1..16bf06f 100644 --- a/tests/MetadataTest.php +++ b/tests/MetadataTest.php @@ -138,14 +138,14 @@ public function testIsIssuerValid() { $this->assertTrue($result); - # ... also with the OpenID Connect document name + # OpenID Connect discovery has its own rules and is not IndieAuth metadata $result = $this->_invokeStaticMethod( Client::class, '_isIssuerValid', ['https://example.com/s/abc/', 'https://example.com/.well-known/openid-configuration/s/abc'] ); - $this->assertTrue($result); + $this->assertFalse($result); # ... and for an issuer without a path (also a prefix match) $result = $this->_invokeStaticMethod( @@ -184,18 +184,10 @@ public function testIsIssuerValid() { $this->assertFalse($result); } - public function testWellKnownMetadataURLs() { - $this->assertEquals([ - 'https://example.com/.well-known/oauth-authorization-server/s/abc', - 'https://example.com/.well-known/openid-configuration/s/abc', - ], Client::wellKnownMetadataURLs('https://example.com/s/abc/')); - - $this->assertEquals([ - 'https://example.com:8443/.well-known/oauth-authorization-server', - 'https://example.com:8443/.well-known/openid-configuration', - ], Client::wellKnownMetadataURLs('https://example.com:8443/')); - - $this->assertEquals([], Client::wellKnownMetadataURLs('not a url')); + public function testWellKnownMetadataURL() { + $this->assertEquals('https://example.com/.well-known/oauth-authorization-server/s/abc', Client::wellKnownMetadataURL('https://example.com/s/abc/')); + $this->assertEquals('https://example.com:8443/.well-known/oauth-authorization-server', Client::wellKnownMetadataURL('https://example.com:8443/')); + $this->assertFalse(Client::wellKnownMetadataURL('not a url')); } public function testDiscoverIssuer()