1616use SimpleSAML \Module \oidc \Utils \AuthenticatedOAuth2ClientResolver ;
1717use SimpleSAML \Module \oidc \Utils \RequestParamsResolver ;
1818use SimpleSAML \Module \oidc \Utils \Routes ;
19+ use SimpleSAML \Module \oidc \ValueAbstracts \IntrospectionAuthorization ;
1920use SimpleSAML \Module \oidc \ValueAbstracts \ResolvedClientAuthenticationMethod ;
2021use SimpleSAML \OpenID \Codebooks \ClaimsEnum ;
2122use SimpleSAML \OpenID \Codebooks \HttpMethodsEnum ;
@@ -53,7 +54,7 @@ public function __construct(
5354 public function __invoke (Request $ request ): Response
5455 {
5556 try {
56- $ this ->ensureAuthenticatedClient ($ request );
57+ $ introspectionAuthorization = $ this ->resolveIntrospectionAuthorization ($ request );
5758 } catch (AuthorizationException $ e ) {
5859 $ this ->loggerService ->error (
5960 'TokenIntrospectionController::invoke: AuthorizationException: ' . $ e ->getMessage (),
@@ -89,21 +90,55 @@ public function __invoke(Request $request): Response
8990
9091 $ payload = null ;
9192 if (is_null ($ tokenTypeHintParam )) {
92- $ payload = $ this ->resolveAccessTokenPayload ($ tokenParam ) ??
93- $ this ->resolveRefreshTokenPayload ($ tokenParam );
93+ $ payload = $ this ->resolveAccessTokenPayload ($ tokenParam, $ introspectionAuthorization ) ??
94+ $ this ->resolveRefreshTokenPayload ($ tokenParam, $ introspectionAuthorization );
9495 } elseif ($ tokenTypeHintParam === 'access_token ' ) {
95- $ payload = $ this ->resolveAccessTokenPayload ($ tokenParam );
96+ $ payload = $ this ->resolveAccessTokenPayload ($ tokenParam, $ introspectionAuthorization );
9697 } elseif ($ tokenTypeHintParam === 'refresh_token ' ) {
97- $ payload = $ this ->resolveRefreshTokenPayload ($ tokenParam );
98+ $ payload = $ this ->resolveRefreshTokenPayload ($ tokenParam, $ introspectionAuthorization );
9899 }
99100
100101 $ payload = $ payload ?? ['active ' => false ];
101102
102103 return $ this ->routes ->newJsonResponse ($ payload );
103104 }
104105
105- protected function resolveAccessTokenPayload (string $ tokenParam ): ?array
106- {
106+ /**
107+ * Whether the caller is to be told about a token issued to the given client, logging any refusal.
108+ *
109+ * Asked with the owner as the token itself gives it, before the payload is assembled: the payload has
110+ * its empty values dropped, so reading the owner back out of it would lose a client identifier which
111+ * PHP considers falsy, and refuse that client its own tokens.
112+ */
113+ protected function isTokenIntrospectableBy (
114+ IntrospectionAuthorization $ introspectionAuthorization ,
115+ mixed $ tokenClientId ,
116+ ): bool {
117+ $ clientId = (is_string ($ tokenClientId ) && $ tokenClientId !== '' ) ? $ tokenClientId : null ;
118+
119+ if ($ introspectionAuthorization ->mayIntrospectTokenOf ($ clientId )) {
120+ return true ;
121+ }
122+
123+ $ this ->loggerService ->warning (
124+ sprintf (
125+ 'Client %s asked about a token which was not issued to it. Answering as if the token ' .
126+ 'was not active. ' ,
127+ (string )$ introspectionAuthorization ->getClientId (),
128+ ),
129+ );
130+
131+ // Deliberately the same answer an expired, revoked or made up token gets. Saying that the token
132+ // exists but is none of the caller's business would turn the endpoint into an oracle it could ask
133+ // about tokens it has come into possession of, which is what RFC 7662 section 2.2 has in mind when
134+ // it has an unauthorized request answered as an inactive token.
135+ return false ;
136+ }
137+
138+ protected function resolveAccessTokenPayload (
139+ string $ tokenParam ,
140+ IntrospectionAuthorization $ introspectionAuthorization ,
141+ ): ?array {
107142 try {
108143 $ accessToken = $ this ->bearerTokenValidator ->ensureValidAccessToken ($ tokenParam );
109144 } catch (\Throwable $ e ) {
@@ -123,6 +158,10 @@ protected function resolveAccessTokenPayload(string $tokenParam): ?array
123158
124159 $ clientId = is_array ($ audience = $ accessToken ->getAudience ()) ? $ audience [0 ] ?? null : null ;
125160
161+ if (!$ this ->isTokenIntrospectableBy ($ introspectionAuthorization , $ clientId )) {
162+ return null ;
163+ }
164+
126165 return array_filter ([
127166 'active ' => true ,
128167 'scope ' => $ scopeClaim ,
@@ -141,8 +180,10 @@ protected function resolveAccessTokenPayload(string $tokenParam): ?array
141180 /**
142181 * @psalm-suppress MixedAssignment
143182 */
144- public function resolveRefreshTokenPayload (string $ tokenParam ): ?array
145- {
183+ protected function resolveRefreshTokenPayload (
184+ string $ tokenParam ,
185+ IntrospectionAuthorization $ introspectionAuthorization ,
186+ ): ?array {
146187 try {
147188 $ decryptedToken = $ this ->oAuth2Bridge ->decrypt ($ tokenParam );
148189 $ tokenData = json_decode ($ decryptedToken , true , 512 , JSON_THROW_ON_ERROR );
@@ -196,6 +237,10 @@ public function resolveRefreshTokenPayload(string $tokenParam): ?array
196237
197238 $ clientId = is_string ($ clientId = $ tokenData ['client_id ' ] ?? null ) ? $ clientId : null ;
198239
240+ if (!$ this ->isTokenIntrospectableBy ($ introspectionAuthorization , $ clientId )) {
241+ return null ;
242+ }
243+
199244 return array_filter ([
200245 'active ' => true ,
201246 'scope ' => $ scopeClaim ,
@@ -218,11 +263,19 @@ protected function prepareScopeString(array $scopes): string
218263 }
219264
220265 /**
266+ * Establish who is asking, and with it which tokens they are entitled to be told about.
267+ *
268+ * Authenticating is not on its own permission to introspect. A client which authenticates as itself is
269+ * held to its own tokens, since anything else would let any registered client - including one which
270+ * registered itself through Dynamic Client Registration - read the subject, scopes and lifetime of
271+ * tokens belonging to every other client of this OP.
272+ *
221273 * @throws AuthorizationException
274+ * @throws \Exception
222275 */
223- protected function ensureAuthenticatedClient (Request $ request ): void
276+ protected function resolveIntrospectionAuthorization (Request $ request ): IntrospectionAuthorization
224277 {
225- $ this ->loggerService ->debug ('TokenIntrospectionController::ensureAuthenticatedClient - start ' );
278+ $ this ->loggerService ->debug ('TokenIntrospectionController::resolveIntrospectionAuthorization - start ' );
226279 $ this ->loggerService ->debug ('Trying supported OAuth2 client authentication methods. ' );
227280
228281 // First, try regular OAuth2 client authentication methods.
@@ -232,15 +285,34 @@ protected function ensureAuthenticatedClient(Request $request): void
232285 $ resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod &&
233286 $ resolvedClientAuthenticationMethod ->getClientAuthenticationMethod ()->isNotNone ()
234287 ) {
288+ $ clientId = $ resolvedClientAuthenticationMethod ->getClient ()->getIdentifier ();
289+
235290 $ this ->loggerService ->debug (
236291 sprintf (
237292 'Client %s authenticated using supported OAuth2 client authentication method %s. ' ,
238- $ resolvedClientAuthenticationMethod -> getClient ()-> getIdentifier () ,
293+ $ clientId ,
239294 $ resolvedClientAuthenticationMethod ->getClientAuthenticationMethod ()->value ,
240295 ),
241296 );
242297
243- return ;
298+ if (
299+ in_array (
300+ $ clientId ,
301+ $ this ->moduleConfig ->getApiOAuth2TokenIntrospectionResourceServerClientIds (),
302+ true ,
303+ )
304+ ) {
305+ $ this ->loggerService ->debug (
306+ sprintf (
307+ 'Client %s is configured as a resource server, so it may introspect any token. ' ,
308+ $ clientId ,
309+ ),
310+ );
311+
312+ return IntrospectionAuthorization::forAnyToken ();
313+ }
314+
315+ return IntrospectionAuthorization::forTokensOfClient ($ clientId );
244316 }
245317
246318 $ this ->loggerService ->debug ('No regular OAuth2 client authentication method found. ' );
@@ -252,5 +324,9 @@ protected function ensureAuthenticatedClient(Request $request): void
252324 );
253325
254326 $ this ->loggerService ->debug ('API client authenticated. ' );
327+
328+ // The administrative path. Reaching it means either a logged in SimpleSAMLphp administrator or an
329+ // API token the deployment issued and scoped by hand, neither of which is tied to a single client.
330+ return IntrospectionAuthorization::forAnyToken ();
255331 }
256332}
0 commit comments