Skip to content

Commit ec153ba

Browse files
authored
Merge pull request #5514 from nickgros/PLFM-9471
PLFM-9471 - RFC-7662 introspection service
2 parents a2137fb + a30110b commit ec153ba

File tree

16 files changed

+538
-5
lines changed

16 files changed

+538
-5
lines changed

client/synapseJavaClient/src/main/java/org/sagebionetworks/client/SynapseClient.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@
253253
import org.sagebionetworks.repo.model.oauth.OAuthProvider;
254254
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformation;
255255
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformationList;
256+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionRequest;
257+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
256258
import org.sagebionetworks.repo.model.oauth.OAuthTokenRevocationRequest;
257259
import org.sagebionetworks.repo.model.oauth.OAuthUrlRequest;
258260
import org.sagebionetworks.repo.model.oauth.OAuthUrlResponse;
@@ -2306,6 +2308,12 @@ OIDCTokenResponse getTokenResponse(
23062308
*/
23072309
void revokeTokenURLEncoded(String token) throws SynapseException, UnsupportedEncodingException;
23082310

2311+
/**
2312+
* Introspects an access token, returning whether it is active and its claims.
2313+
* Authenticated by the calling Synapse user (not an OAuth client).
2314+
*/
2315+
OAuthTokenIntrospectionResponse introspectToken(OAuthTokenIntrospectionRequest request) throws SynapseException;
2316+
23092317
/**
23102318
* Updates the metadata for a particular refresh token.
23112319
* @param metadata

client/synapseJavaClient/src/main/java/org/sagebionetworks/client/SynapseClientImpl.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@
293293
import org.sagebionetworks.repo.model.oauth.OAuthProvider;
294294
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformation;
295295
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformationList;
296+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionRequest;
297+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
296298
import org.sagebionetworks.repo.model.oauth.OAuthTokenRevocationRequest;
297299
import org.sagebionetworks.repo.model.oauth.OAuthUrlRequest;
298300
import org.sagebionetworks.repo.model.oauth.OAuthUrlResponse;
@@ -626,6 +628,7 @@ public class SynapseClientImpl extends BaseClientImpl implements SynapseClient {
626628
public static final String AUTH_OAUTH_2_AUDIT_CLIENTS = AUTH_OAUTH_2_AUDIT + "/grantedClients";
627629
public static final String METADATA = "/metadata";
628630
public static final String REVOKE = "/revoke";
631+
public static final String AUTH_OAUTH_2_INTROSPECT = AUTH_OAUTH_2 + "/introspect";
629632
public static final String TOKENS = "/tokens";
630633

631634
public static final String AUTH_OAUTH_2_GRANT_TYPE_PARAM = "grant_type";
@@ -4822,6 +4825,12 @@ public void revokeTokenURLEncoded(String token) throws SynapseException, Unsuppo
48224825
ClientUtils.checkStatusCodeAndThrowException(response);
48234826
}
48244827

4828+
@Override
4829+
public OAuthTokenIntrospectionResponse introspectToken(OAuthTokenIntrospectionRequest request) throws SynapseException {
4830+
ValidateArgument.required(request, "request");
4831+
return postJSONEntity(getAuthEndpoint(), AUTH_OAUTH_2_INTROSPECT, request, OAuthTokenIntrospectionResponse.class);
4832+
}
4833+
48254834
@Override
48264835
public OAuthRefreshTokenInformation updateRefreshTokenMetadata(OAuthRefreshTokenInformation metadata) throws SynapseException {
48274836
ValidateArgument.required(metadata, "metadata");

integration-test/src/test/java/org/sagebionetworks/IT960TermsOfUse.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.junit.jupiter.api.Assertions.assertNotNull;
66
import static org.junit.jupiter.api.Assertions.assertNull;
77
import static org.junit.jupiter.api.Assertions.assertThrows;
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
89

910
import java.time.Instant;
1011
import java.time.temporal.ChronoUnit;
@@ -32,6 +33,8 @@
3233
import org.sagebionetworks.repo.model.auth.TermsOfServiceState;
3334
import org.sagebionetworks.repo.model.auth.TermsOfServiceStatus;
3435
import org.sagebionetworks.repo.model.file.ExternalFileHandle;
36+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionRequest;
37+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
3538
import org.sagebionetworks.warehouse.WarehouseTestHelper;
3639

3740
@ExtendWith(ITTestExtension.class)
@@ -171,6 +174,22 @@ public void testGetUserTermsOfServiceStatus() throws SynapseException {
171174
assertNotNull(status.getLastAgreementVersion());
172175
}
173176

177+
@Test
178+
public void testIntrospectTokenWithoutAcceptingTermsOfUse() throws SynapseException {
179+
// A user who has not accepted the ToS should still be able to introspect a token
180+
String token = rejectTOUsynapse.getAccessToken();
181+
182+
OAuthTokenIntrospectionRequest request = new OAuthTokenIntrospectionRequest();
183+
request.setToken(token);
184+
185+
OAuthTokenIntrospectionResponse response = rejectTOUsynapse.introspectToken(request);
186+
187+
assertTrue(response.getActive());
188+
assertNotNull(response.getSub());
189+
assertNotNull(response.getExp());
190+
assertNotNull(response.getAud());
191+
}
192+
174193
@Test
175194
public void testUpdateTermsOfServiceRequirments(SynapseAdminClient adminSynapse) throws SynapseException {
176195
TermsOfServiceInfo info = synapse.getTermsOfServiceInfo();

integration-test/src/test/java/org/sagebionetworks/ITOpenIDConnectTest.java

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import static org.junit.jupiter.api.Assertions.assertNull;
88
import static org.junit.jupiter.api.Assertions.assertThrows;
99
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
import static org.sagebionetworks.client.ClientUtils.createBasicAuthorizationHeader;
1011

1112
import java.util.Collections;
1213
import java.util.HashMap;
@@ -44,6 +45,7 @@
4445
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformationList;
4546
import org.sagebionetworks.repo.model.oauth.OAuthResponseType;
4647
import org.sagebionetworks.repo.model.oauth.OAuthScope;
48+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
4749
import org.sagebionetworks.repo.model.oauth.OAuthTokenRevocationRequest;
4850
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequest;
4951
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequestDescription;
@@ -54,6 +56,7 @@
5456
import org.sagebionetworks.repo.model.oauth.OIDCTokenResponse;
5557
import org.sagebionetworks.repo.model.oauth.OIDConnectConfiguration;
5658
import org.sagebionetworks.repo.model.oauth.TokenTypeHint;
59+
import org.sagebionetworks.schema.adapter.org.json.JSONObjectAdapterImpl;
5760
import org.sagebionetworks.simpleHttpClient.SimpleHttpClient;
5861
import org.sagebionetworks.simpleHttpClient.SimpleHttpClientImpl;
5962
import org.sagebionetworks.simpleHttpClient.SimpleHttpRequest;
@@ -353,7 +356,22 @@ public void testRoundTrip() throws Exception {
353356
assertEquals(email, idClaims.get("email", String.class));
354357
assertEquals(Collections.EMPTY_LIST, idClaims.get("team", List.class));
355358
assertEquals(nonce, idClaims.get("nonce"));
356-
359+
360+
// introspect the ID token
361+
{
362+
SimpleHttpRequest request = new SimpleHttpRequest();
363+
request.setUri(config.getAuthenticationServicePublicEndpoint()+"/oauth2/introspect");
364+
Map<String, String> requestHeaders = new HashMap<>();
365+
requestHeaders.put("Content-Type", "application/x-www-form-urlencoded");
366+
request.setHeaders(requestHeaders);
367+
String requestBody = "token="+tokenResponse.getId_token();
368+
SimpleHttpResponse response = simpleClient.post(request, requestBody);
369+
assertEquals(HttpStatus.SC_OK, response.getStatusCode());
370+
assertNotNull(response.getContent());
371+
OAuthTokenIntrospectionResponse introspectionResponse = new OAuthTokenIntrospectionResponse(new JSONObjectAdapterImpl(response.getContent()));
372+
assertTrue(introspectionResponse.getActive());
373+
}
374+
357375
// the access token encodes claims we can refresh
358376
Jwt<JwsHeader, Claims> parsedAccessToken = JSONWebTokenHelper.parseJWT(tokenResponse.getAccess_token(), jsonWebKeySet);
359377
Claims accessClaims = parsedAccessToken.getBody();
@@ -367,6 +385,21 @@ public void testRoundTrip() throws Exception {
367385
assertTrue(userInfoClaims.containsKey("is_certified"));
368386
assertTrue(userInfoClaims.containsKey("team"));
369387

388+
// introspect the access token
389+
{
390+
SimpleHttpRequest request = new SimpleHttpRequest();
391+
request.setUri(config.getAuthenticationServicePublicEndpoint()+"/oauth2/introspect");
392+
Map<String, String> requestHeaders = new HashMap<>();
393+
requestHeaders.put("Content-Type", "application/x-www-form-urlencoded");
394+
request.setHeaders(requestHeaders);
395+
String requestBody = "token="+tokenResponse.getAccess_token();
396+
SimpleHttpResponse response = simpleClient.post(request, requestBody);
397+
assertEquals(HttpStatus.SC_OK, response.getStatusCode());
398+
assertNotNull(response.getContent());
399+
OAuthTokenIntrospectionResponse introspectionResponse = new OAuthTokenIntrospectionResponse(new JSONObjectAdapterImpl(response.getContent()));
400+
assertTrue(introspectionResponse.getActive());
401+
}
402+
370403
// Note, we use a bearer token to authorize the client
371404
try {
372405
synapseClientForOAuthClient.setBearerAuthorizationToken(tokenResponse.getAccess_token());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"description": "Request to introspect an OAuth 2.0 access token, loosely based on <a href=\"https://tools.ietf.org/html/rfc7662\">RFC 7662</a>.",
3+
"properties": {
4+
"token": {
5+
"type": "string",
6+
"description": "The access token to introspect"
7+
},
8+
"max_age": {
9+
"type": "integer",
10+
"description": "Optional maximum authentication age in seconds. If the user's authentication occurred more than this many seconds ago, the token is considered inactive."
11+
}
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"description": "Response from token introspection conforming to <a href=\"https://tools.ietf.org/html/rfc7662\">RFC 7662</a>.",
3+
"properties": {
4+
"active": {
5+
"type": "boolean",
6+
"description": "Whether the token is active"
7+
},
8+
"scope": {
9+
"type": "string",
10+
"description": "A JSON string containing a space-separated list of scopes associated with this token, in the format described in Section 3.3 of OAuth 2.0"
11+
},
12+
"aud": {
13+
"type": "string",
14+
"description": "The audience (realm or OAuth Client) the token was issued for"
15+
},
16+
"sub": {
17+
"type": "string",
18+
"description": "Subject identifier. For a first-party access token, this is the Synapse user ID. For tokens issued to OAuth Clients, this is a PPID (Pairwise Pseudonymous Identifier)"
19+
},
20+
"token_type": {
21+
"$ref": "org.sagebionetworks.repo.model.auth.TokenType"
22+
},
23+
"exp": {
24+
"type": "integer",
25+
"description": "Expiration time (seconds since epoch)"
26+
},
27+
"iat": {
28+
"type": "integer",
29+
"description": "Issued at time (seconds since epoch)"
30+
},
31+
"auth_time": {
32+
"type": "integer",
33+
"description": "Time of user authentication (seconds since epoch)"
34+
},
35+
"iss": {
36+
"type": "string",
37+
"description": "Token issuer"
38+
},
39+
"jti": {
40+
"type": "string",
41+
"description": "Unique token identifier"
42+
}
43+
}
44+
}

lib/lib-auto-generated/src/main/resources/schema/org/sagebionetworks/repo/model/oauth/OIDConnectConfiguration.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"type": "string",
2222
"description": "URL of the Synapse UserInfo Endpoint"
2323
},
24+
"introspection_endpoint": {
25+
"type": "string",
26+
"description": "URL of the Synapse OAuth 2.0 Token Introspection Endpoint"
27+
},
2428
"jwks_uri": {
2529
"type": "string",
2630
"description": "URL of the Synapse JSON Web Key Set [JWK] document."

services/repository-managers/src/main/java/org/sagebionetworks/repo/manager/oauth/OpenIDConnectManager.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.sagebionetworks.repo.model.UnauthorizedException;
55
import org.sagebionetworks.repo.model.UserInfo;
66
import org.sagebionetworks.repo.model.oauth.OAuthAuthorizationResponse;
7+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
78
import org.sagebionetworks.repo.model.oauth.OAuthTokenRevocationRequest;
89
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequest;
910
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequestDescription;
@@ -100,4 +101,13 @@ public static String getScopeHash(OIDCAuthorizationRequest authorizationRequest)
100101
*/
101102
void revokeUserAccess(Long userId);
102103

104+
/**
105+
* Introspect an OAuth 2.0 access token, returning whether the token is active and its claims.
106+
*
107+
* @param token the access token to introspect
108+
* @param maxAge optional maximum authentication age in seconds
109+
* @return the introspection response
110+
*/
111+
OAuthTokenIntrospectionResponse introspectToken(String token, Long maxAge);
112+
103113
}

services/repository-managers/src/main/java/org/sagebionetworks/repo/manager/oauth/OpenIDConnectManagerImpl.java

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.util.Set;
1515
import java.util.TreeSet;
1616
import java.util.UUID;
17+
import java.util.stream.Collectors;
1718

1819
import org.apache.commons.lang3.StringUtils;
1920
import org.sagebionetworks.manager.util.OAuthPermissionUtils;
@@ -22,7 +23,6 @@
2223
import org.sagebionetworks.repo.manager.authentication.PersonalAccessTokenManager;
2324
import org.sagebionetworks.repo.manager.oauth.claimprovider.OIDCClaimProvider;
2425
import org.sagebionetworks.repo.model.AuthorizationConstants;
25-
import org.sagebionetworks.repo.model.AuthorizationUtils;
2626
import org.sagebionetworks.repo.model.UserInfo;
2727
import org.sagebionetworks.repo.model.auth.AuthenticationDAO;
2828
import org.sagebionetworks.repo.model.auth.OAuthClientDao;
@@ -33,6 +33,7 @@
3333
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformation;
3434
import org.sagebionetworks.repo.model.oauth.OAuthResponseType;
3535
import org.sagebionetworks.repo.model.oauth.OAuthScope;
36+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
3637
import org.sagebionetworks.repo.model.oauth.OAuthTokenRevocationRequest;
3738
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequest;
3839
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequestDescription;
@@ -692,4 +693,101 @@ public void revokeUserAccess(Long userId) {
692693
oauthDao.deleteAllAuthorizationConsents(userId);
693694
}
694695

696+
private static OAuthTokenIntrospectionResponse inactiveIntrospectionResponse() {
697+
return new OAuthTokenIntrospectionResponse().setActive(false);
698+
}
699+
700+
@Override
701+
public OAuthTokenIntrospectionResponse introspectToken(String token, Long maxAge) {
702+
ValidateArgument.required(token, "token");
703+
704+
// Parse the JWT. If parsing fails (expired, invalid signature, etc.), the token is inactive.
705+
Jwt<JwsHeader, Claims> jwt;
706+
try {
707+
jwt = oidcTokenManager.parseJWT(token);
708+
} catch (Exception e) {
709+
return inactiveIntrospectionResponse();
710+
}
711+
712+
Claims claims = jwt.getBody();
713+
714+
// Check token type — only OIDC_ACCESS_TOKEN and PERSONAL_ACCESS_TOKEN are valid
715+
TokenType tokenType;
716+
try {
717+
tokenType = TokenType.valueOf(claims.get(OIDCClaimName.token_type.name(), String.class));
718+
} catch (Exception e) {
719+
return inactiveIntrospectionResponse();
720+
}
721+
722+
switch (tokenType) {
723+
case OIDC_ACCESS_TOKEN:
724+
String tokenId = claims.getId();
725+
if (!oidcTokenManager.doesOIDCAccessTokenExist(tokenId)) {
726+
return inactiveIntrospectionResponse();
727+
}
728+
String refreshTokenId = claims.get(OIDCClaimName.refresh_token_id.name(), String.class);
729+
if (refreshTokenId != null && !oauthRefreshTokenManager.isRefreshTokenActive(refreshTokenId)) {
730+
return inactiveIntrospectionResponse();
731+
}
732+
break;
733+
case PERSONAL_ACCESS_TOKEN:
734+
String personalAccessTokenId = claims.getId();
735+
if (!personalAccessTokenManager.isTokenActive(personalAccessTokenId)) {
736+
return inactiveIntrospectionResponse();
737+
}
738+
break;
739+
case OIDC_ID_TOKEN:
740+
// ID tokens cannot be "revoked", so there is nothing to check.
741+
// The parse step above would have thrown an exception if it expired
742+
break;
743+
default:
744+
return inactiveIntrospectionResponse();
745+
}
746+
747+
// Check max_age if provided
748+
// auth_time is stored as seconds since epoch in the JWT
749+
Number authTimeValue = claims.get(OIDCClaimName.auth_time.name(), Number.class);
750+
Long authTimeSeconds = authTimeValue != null ? authTimeValue.longValue() : null;
751+
752+
if (maxAge != null && authTimeSeconds != null) {
753+
long nowSeconds = clock.currentTimeMillis() / 1000L;
754+
if (nowSeconds - authTimeSeconds > maxAge) {
755+
return inactiveIntrospectionResponse();
756+
}
757+
} else if (maxAge != null && authTimeSeconds == null) {
758+
// max_age was requested but auth_time is not available in the token
759+
// This should never happen, but fail the request since we cannot guarantee the token is still valid
760+
return inactiveIntrospectionResponse();
761+
}
762+
763+
// Build the active response with claims from the JWT
764+
OAuthTokenIntrospectionResponse response = new OAuthTokenIntrospectionResponse();
765+
response.setActive(true);
766+
response.setSub(claims.getSubject());
767+
response.setAud(claims.getAudience());
768+
response.setIss(claims.getIssuer());
769+
response.setJti(claims.getId());
770+
response.setToken_type(tokenType);
771+
772+
if (claims.getExpiration() != null) {
773+
response.setExp(claims.getExpiration().getTime() / 1000L);
774+
}
775+
if (claims.getIssuedAt() != null) {
776+
response.setIat(claims.getIssuedAt().getTime() / 1000L);
777+
}
778+
if (authTimeSeconds != null) {
779+
response.setAuth_time(authTimeSeconds);
780+
}
781+
782+
// space-delimited string containing all scopes
783+
response.setScope(ClaimsJsonUtil.getScopeFromClaims(claims)
784+
.stream()
785+
.map(OAuthScope::name)
786+
.collect(Collectors.joining(" "))
787+
);
788+
789+
return response;
790+
}
791+
792+
695793
}

services/repository-managers/src/main/java/org/sagebionetworks/repo/service/auth/OpenIDConnectService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import org.sagebionetworks.repo.model.oauth.OAuthGrantType;
1212
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformation;
1313
import org.sagebionetworks.repo.model.oauth.OAuthRefreshTokenInformationList;
14+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionRequest;
15+
import org.sagebionetworks.repo.model.oauth.OAuthTokenIntrospectionResponse;
1416
import org.sagebionetworks.repo.model.oauth.OAuthTokenRevocationRequest;
1517
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequest;
1618
import org.sagebionetworks.repo.model.oauth.OIDCAuthorizationRequestDescription;
@@ -173,4 +175,6 @@ public OIDCTokenResponse getTokenResponse(String verifiedClientId, OAuthGrantTyp
173175
OAuthRefreshTokenInformation getRefreshTokenMetadataAsUser(Long userId, String tokenId);
174176

175177
OAuthRefreshTokenInformation getRefreshTokenMetadataAsClient(String verifiedClientId, String tokenId);
178+
179+
OAuthTokenIntrospectionResponse introspectToken(Long userId, OAuthTokenIntrospectionRequest request);
176180
}

0 commit comments

Comments
 (0)