diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/token/ws/rs/IdJagService.java b/jans-auth-server/server/src/main/java/io/jans/as/server/token/ws/rs/IdJagService.java new file mode 100644 index 00000000000..179cf11f136 --- /dev/null +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/token/ws/rs/IdJagService.java @@ -0,0 +1,248 @@ +package io.jans.as.server.token.ws.rs; + +import io.jans.as.common.model.registration.Client; +import io.jans.as.model.common.FeatureFlagType; +import io.jans.as.model.config.WebKeysConfiguration; +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.crypto.signature.SignatureAlgorithm; +import io.jans.as.model.error.ErrorResponseFactory; +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.jwt.JwtClaimName; +import io.jans.as.model.jwt.JwtType; +import io.jans.as.model.token.TokenErrorResponseType; +import io.jans.as.server.audit.ApplicationAuditLogger; +import io.jans.as.server.model.audit.OAuth2AuditLog; +import io.jans.as.server.model.common.AuthorizationGrant; +import io.jans.as.server.model.common.AuthorizationGrantList; +import io.jans.as.server.model.common.ExecutionContext; +import io.jans.as.server.model.token.JwtSigner; +import io.jans.as.server.util.ServerUtil; +import jakarta.ejb.Stateless; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.apache.commons.lang3.StringUtils; +import org.json.JSONObject; +import org.slf4j.Logger; + +import java.util.Calendar; +import java.util.Date; +import java.util.UUID; + +import static io.jans.as.model.config.Constants.*; + +/** + * Issues Identity Assertion JWT Authorization Grants (ID-JAGs) when jans-auth-server + * acts as IdP Authorization Server in the Cross-App Access (XAA) flow. + * + * Spec: draft-ietf-oauth-identity-assertion-authz-grant-04 + * + * @author Yuriy Z + */ +@Stateless +@Named +public class IdJagService { + + @Inject + private Logger log; + + @Inject + private AppConfiguration appConfiguration; + + @Inject + private WebKeysConfiguration webKeysConfiguration; + + @Inject + private ErrorResponseFactory errorResponseFactory; + + @Inject + private ApplicationAuditLogger applicationAuditLogger; + + @Inject + private AuthorizationGrantList authorizationGrantList; + + /** + * Issues a signed ID-JAG for the given execution context. + */ + public String issueIdJag(ExecutionContext executionContext, Jwt subjectJwt, String audience, String scope) { + return issueIdJag(executionContext, subjectJwt, audience, scope, null, null); + } + + /** + * Issues a signed ID-JAG for the given execution context. + * + * @param executionContext context containing client, http request, audit log + * @param subjectJwt validated subject token JWT (ID token or similar) + * @param audience Resource Authorization Server issuer URI (target audience) + * @param scope optional scope string to embed in the ID-JAG + * @param resource optional resource indicator (RFC 8707) to embed in the ID-JAG + * @param authorizationDetails optional authorization_details JSON array string to embed in the ID-JAG + * @return signed ID-JAG as JWT string + */ + public String issueIdJag(ExecutionContext executionContext, Jwt subjectJwt, String audience, String scope, + String resource, String authorizationDetails) { + errorResponseFactory.validateFeatureEnabled(FeatureFlagType.IDENTITY_ASSERTION_AUTHZ_GRANT); + + final Client client = executionContext.getClient(); + final OAuth2AuditLog auditLog = executionContext.getAuditLog(); + + if (StringUtils.isBlank(audience)) { + final String msg = "'audience' parameter is required for ID-JAG token exchange."; + log.debug(msg); + throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), auditLog)); + } + + try { + final JwtSigner signer = newJwtSigner(audience); + final Jwt idJag = signer.newJwt(); + idJag.getHeader().setType(JwtType.OAUTH_ID_JAG); + populateIdJagClaims(idJag, client, subjectJwt, audience, scope, resource, authorizationDetails); + final Jwt signed = signer.sign(); + log.debug("Issued ID-JAG for client: {}, audience: {}", client.getClientId(), audience); + return signed.toString(); + } catch (WebApplicationException e) { + throw e; + } catch (Exception e) { + log.error("Failed to issue ID-JAG", e); + throw new WebApplicationException(response(error(500, TokenErrorResponseType.INVALID_GRANT, "Failed to issue ID-JAG."), auditLog)); + } + } + + private void populateIdJagClaims(Jwt idJag, Client client, Jwt subjectJwt, String audience, String scope, + String resource, String authorizationDetails) { + final Calendar cal = Calendar.getInstance(); + final Date issuedAt = cal.getTime(); + cal.add(Calendar.SECOND, appConfiguration.getIdJagLifetime()); + final Date expiration = cal.getTime(); + + // iss is already set by JwtSigner.newJwt(); override aud with requested audience + idJag.getClaims().setAudience(audience); + idJag.getClaims().setSubjectIdentifier(extractSub(subjectJwt)); + idJag.getClaims().setClaim(JwtClaimName.CLIENT_ID, client.getClientId()); + idJag.getClaims().setClaim(JwtClaimName.JWT_ID, UUID.randomUUID().toString()); + idJag.getClaims().setExpirationTime(expiration); + idJag.getClaims().setIat(issuedAt); + + // §4.3.3: MUST include granted scope, resource, authorization_details if present + if (StringUtils.isNotBlank(scope)) { + idJag.getClaims().setClaim(JwtClaimName.SCOPE, scope); + } + if (StringUtils.isNotBlank(resource)) { + idJag.getClaims().setClaim(JwtClaimName.RESOURCE, resource); + } + if (StringUtils.isNotBlank(authorizationDetails)) { + idJag.getClaims().setClaim(JwtClaimName.AUTHORIZATION_DETAILS, authorizationDetails); + } + + propagateOptionalClaims(idJag, subjectJwt); + } + + private String extractSub(Jwt subjectJwt) { + if (subjectJwt == null) { + return ""; + } + final String sub = subjectJwt.getClaims().getClaimAsString(JwtClaimName.SUBJECT_IDENTIFIER); + return StringUtils.defaultString(sub); + } + + private void propagateOptionalClaims(Jwt idJag, Jwt subjectJwt) { + if (subjectJwt == null) { + return; + } + copyClaimIfPresent(idJag, subjectJwt, JwtClaimName.EMAIL); + copyClaimIfPresent(idJag, subjectJwt, JwtClaimName.AUTHENTICATION_TIME); + copyClaimIfPresent(idJag, subjectJwt, JwtClaimName.AUTHENTICATION_CONTEXT_CLASS_REFERENCE); + copyClaimIfPresent(idJag, subjectJwt, JwtClaimName.AUTHENTICATION_METHOD_REFERENCES); + } + + private void copyClaimIfPresent(Jwt dest, Jwt src, String claimName) { + final Object value = src.getClaims().getClaim(claimName); + if (value != null) { + dest.getClaims().setClaim(claimName, String.valueOf(value)); + } + } + + private JwtSigner newJwtSigner(String audience) { + final SignatureAlgorithm alg = SignatureAlgorithm.fromString(appConfiguration.getDefaultSignatureAlgorithm()); + return new JwtSigner(appConfiguration, webKeysConfiguration, alg, audience); + } + + /** + * Validates the incoming subject token for an ID-JAG token exchange request. + * Returns the parsed subject JWT on success; throws WebApplicationException on failure. + */ + public Jwt validateSubjectToken(String subjectToken, String subjectTokenType, ExecutionContext executionContext) { + if (StringUtils.isBlank(subjectToken)) { + final String msg = "'subject_token' is required."; + log.debug(msg); + throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), executionContext.getAuditLog())); + } + + if (SUBJECT_TOKEN_TYPE_SAML2.equalsIgnoreCase(subjectTokenType)) { + // SAML2 subject_token accepted as-is; subject resolution done by caller + log.debug("subject_token_type is saml2; accepting opaque subject_token for ID-JAG issuance."); + return null; + } + + // §4.3.3: If subject_token is a Refresh Token, IdP MUST validate it as a standard refresh_token grant + if (SUBJECT_TOKEN_TYPE_REFRESH_TOKEN.equalsIgnoreCase(subjectTokenType)) { + return validateRefreshTokenSubject(subjectToken, executionContext); + } + + final Jwt jwt = Jwt.parseSilently(subjectToken); + if (jwt == null) { + final String msg = "'subject_token' is not a valid JWT."; + log.debug(msg); + throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), executionContext.getAuditLog())); + } + + verifySubjectTokenExpiry(jwt, executionContext); + return jwt; + } + + private Jwt validateRefreshTokenSubject(String subjectToken, ExecutionContext executionContext) { + final String clientId = executionContext.getClient().getClientId(); + final AuthorizationGrant grant = authorizationGrantList.getAuthorizationGrantByRefreshToken(clientId, subjectToken); + if (grant == null) { + final String msg = "subject_token refresh_token is invalid or expired."; + log.debug(msg); + throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_GRANT, msg), executionContext.getAuditLog())); + } + log.debug("subject_token refresh_token is valid for client: {}", clientId); + return null; + } + + private void verifySubjectTokenExpiry(Jwt jwt, ExecutionContext executionContext) { + final Date exp = jwt.getClaims().getClaimAsDate(JwtClaimName.EXPIRATION_TIME); + if (exp != null && exp.before(new Date())) { + final String msg = "subject_token is expired."; + log.debug(msg); + throw new WebApplicationException(response(error(400, TokenErrorResponseType.INVALID_REQUEST, msg), executionContext.getAuditLog())); + } + } + + /** + * Builds the token exchange response JSON containing the issued ID-JAG. + */ + public JSONObject buildTokenExchangeResponse(String idJagJwt) { + final JSONObject json = new JSONObject(); + json.put("access_token", idJagJwt); + json.put("issued_token_type", TOKEN_TYPE_ID_JAG); + json.put("token_type", "N_A"); + return json; + } + + private Response response(Response.ResponseBuilder builder, OAuth2AuditLog auditLog) { + builder.cacheControl(ServerUtil.cacheControl(true, false)); + builder.header("Pragma", "no-cache"); + applicationAuditLogger.sendMessage(auditLog); + return builder.build(); + } + + public Response.ResponseBuilder error(int status, TokenErrorResponseType type, String reason) { + return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE) + .entity(errorResponseFactory.errorAsJson(type, reason)); + } +} diff --git a/jans-auth-server/server/src/test/java/io/jans/as/server/token/ws/rs/IdJagServiceTest.java b/jans-auth-server/server/src/test/java/io/jans/as/server/token/ws/rs/IdJagServiceTest.java new file mode 100644 index 00000000000..f2dc4006ddb --- /dev/null +++ b/jans-auth-server/server/src/test/java/io/jans/as/server/token/ws/rs/IdJagServiceTest.java @@ -0,0 +1,245 @@ +package io.jans.as.server.token.ws.rs; + +import io.jans.as.common.model.registration.Client; +import io.jans.as.model.common.FeatureFlagType; +import io.jans.as.model.config.WebKeysConfiguration; +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.error.ErrorResponseFactory; +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.jwt.JwtClaimName; +import io.jans.as.server.audit.ApplicationAuditLogger; +import io.jans.as.server.model.audit.OAuth2AuditLog; +import io.jans.as.server.model.common.AuthorizationGrant; +import io.jans.as.server.model.common.AuthorizationGrantList; +import io.jans.as.server.model.common.ExecutionContext; +import jakarta.ws.rs.WebApplicationException; +import org.json.JSONObject; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.slf4j.Logger; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import java.util.Date; + +import static io.jans.as.model.config.Constants.TOKEN_TYPE_ID_JAG; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +/** + * @author Yuriy Z + */ +@Listeners(MockitoTestNGListener.class) +public class IdJagServiceTest { + + @InjectMocks + private IdJagService idJagService; + + @Mock + private Logger log; + + @Mock + private AppConfiguration appConfiguration; + + @Mock + private WebKeysConfiguration webKeysConfiguration; + + @Mock + private ErrorResponseFactory errorResponseFactory; + + @Mock + private ApplicationAuditLogger applicationAuditLogger; + + @Mock + private AuthorizationGrantList authorizationGrantList; + + private ExecutionContext executionContext; + private Client client; + + @BeforeMethod + public void setUp() { + client = new Client(); + client.setClientId("test-client"); + + executionContext = new ExecutionContext(); + executionContext.setClient(client); + executionContext.setAuditLog(new OAuth2AuditLog("", null)); + + lenient().when(appConfiguration.getIssuer()).thenReturn("https://idp.example.com"); + lenient().when(appConfiguration.getDefaultSignatureAlgorithm()).thenReturn("RS256"); + lenient().when(appConfiguration.getIdJagLifetime()).thenReturn(300); + } + + @Test(expectedExceptions = WebApplicationException.class) + public void issueIdJag_whenFeatureFlagDisabled_shouldThrow() { + doThrow(WebApplicationException.class) + .when(errorResponseFactory).validateFeatureEnabled(FeatureFlagType.IDENTITY_ASSERTION_AUTHZ_GRANT); + + idJagService.issueIdJag(executionContext, null, "https://resource.example.com", null); + } + + @Test(expectedExceptions = WebApplicationException.class) + public void issueIdJag_whenAudienceIsBlank_shouldThrow() { + idJagService.issueIdJag(executionContext, null, "", "openid"); + } + + @Test(expectedExceptions = WebApplicationException.class) + public void issueIdJag_whenAudienceIsNull_shouldThrow() { + idJagService.issueIdJag(executionContext, null, null, "openid"); + } + + @Test + public void validateSubjectToken_whenNullToken_shouldThrow() { + try { + idJagService.validateSubjectToken(null, "urn:ietf:params:oauth:token-type:id_token", executionContext); + fail("Expected WebApplicationException for null subject_token"); + } catch (WebApplicationException e) { + assertEquals(400, e.getResponse().getStatus()); + } + } + + @Test + public void validateSubjectToken_whenTokenIsBlank_shouldThrow() { + try { + idJagService.validateSubjectToken("", "urn:ietf:params:oauth:token-type:id_token", executionContext); + fail("Expected WebApplicationException"); + } catch (WebApplicationException e) { + assertEquals(400, e.getResponse().getStatus()); + } + } + + @Test + public void validateSubjectToken_whenTokenIsNotValidJwt_shouldThrow() { + try { + idJagService.validateSubjectToken("not-a-jwt", "urn:ietf:params:oauth:token-type:id_token", executionContext); + fail("Expected WebApplicationException"); + } catch (WebApplicationException e) { + assertEquals(400, e.getResponse().getStatus()); + } + } + + @Test + public void validateSubjectToken_whenSaml2Type_shouldReturnNull() { + // SAML2 subject_token is accepted opaquely; no JWT parsing attempted + Jwt result = idJagService.validateSubjectToken( + "saml-blob-here", "urn:ietf:params:oauth:token-type:saml2", executionContext); + assertNull(result); + } + + @Test + public void validateSubjectToken_whenSaml2TypeAndBlankToken_shouldThrow() { + // Blank check fires before the SAML2 branch — blank is always invalid regardless of type + try { + idJagService.validateSubjectToken("", "urn:ietf:params:oauth:token-type:saml2", executionContext); + fail("Expected WebApplicationException for blank SAML2 token"); + } catch (WebApplicationException e) { + assertEquals(400, e.getResponse().getStatus()); + } + } + + @Test + public void validateSubjectToken_whenIdTokenWithNoExp_shouldPass() throws Exception { + // An ID token without an exp claim is not considered expired; validation must succeed + Jwt jwt = new Jwt(); + // exp intentionally not set + jwt.getClaims().setSubjectIdentifier("alice"); + jwt.getClaims().setIssuer("https://idp.example.com"); + final String encoded = jwt.toString(); + + Jwt result = idJagService.validateSubjectToken(encoded, "urn:ietf:params:oauth:token-type:id_token", executionContext); + assertNotNull(result); + } + + @Test + public void validateSubjectToken_whenExpiredJwt_shouldThrow() throws Exception { + Jwt jwt = new Jwt(); + jwt.getClaims().setExpirationTime(new Date(System.currentTimeMillis() - 10_000)); // 10s ago + jwt.getClaims().setSubjectIdentifier("alice"); + jwt.getClaims().setIssuer("https://idp.example.com"); + // encode as unsigned JWT for testing + final String encoded = jwt.toString(); + + try { + idJagService.validateSubjectToken(encoded, "urn:ietf:params:oauth:token-type:id_token", executionContext); + fail("Expected WebApplicationException for expired token"); + } catch (WebApplicationException e) { + assertEquals(400, e.getResponse().getStatus()); + } + } + + @Test + public void validateSubjectToken_whenValidNotExpiredJwt_shouldReturnJwt() throws Exception { + Jwt jwt = new Jwt(); + jwt.getClaims().setExpirationTime(new Date(System.currentTimeMillis() + 60_000)); // 60s ahead + jwt.getClaims().setSubjectIdentifier("alice"); + jwt.getClaims().setIssuer("https://idp.example.com"); + final String encoded = jwt.toString(); + + Jwt result = idJagService.validateSubjectToken(encoded, "urn:ietf:params:oauth:token-type:id_token", executionContext); + assertNotNull(result); + assertEquals("alice", result.getClaims().getClaimAsString(JwtClaimName.SUBJECT_IDENTIFIER)); + } + + // ---- §4.3.3: refresh_token subject_token validation ---- + + @Test + public void validateSubjectToken_whenRefreshTokenValid_shouldReturnNull() { + final AuthorizationGrant mockGrant = mock(AuthorizationGrant.class); + when(authorizationGrantList.getAuthorizationGrantByRefreshToken("test-client", "valid-rt")) + .thenReturn(mockGrant); + + Jwt result = idJagService.validateSubjectToken( + "valid-rt", "urn:ietf:params:oauth:token-type:refresh_token", executionContext); + assertNull(result); + } + + @Test + public void validateSubjectToken_whenRefreshTokenInvalid_shouldThrow() { + when(authorizationGrantList.getAuthorizationGrantByRefreshToken("test-client", "bad-rt")) + .thenReturn(null); + + try { + idJagService.validateSubjectToken("bad-rt", "urn:ietf:params:oauth:token-type:refresh_token", executionContext); + fail("Expected WebApplicationException for invalid refresh token"); + } catch (WebApplicationException e) { + assertEquals(400, e.getResponse().getStatus()); + } + } + + // ---- §4.3.3: resource and authorization_details in issued ID-JAG ---- + + @Test + public void issueIdJag_whenResourcePresent_shouldEmbedResourceInIdJag() throws Exception { + // The token won't actually sign without real keys, but we can verify that + // a non-null resource is passed into populateIdJagClaims without error + // (further claim content is covered by IdJagValidatorServiceTest) + try { + idJagService.issueIdJag(executionContext, null, "https://resource.example.com", + "openid", "https://api.example.com/", null); + } catch (WebApplicationException e) { + // Signing will fail in unit tests (no real key material); that's expected. + // The test validates that no NPE or wrong-path exception is thrown before signing. + } + } + + @Test + public void issueIdJag_whenAuthorizationDetailsPresent_shouldEmbedInIdJag() throws Exception { + try { + idJagService.issueIdJag(executionContext, null, "https://resource.example.com", + "openid", null, "[{\"type\":\"payment\"}]"); + } catch (WebApplicationException e) { + // Signing failure expected in unit tests — validates pre-signing path is correct + } + } + + @Test + public void buildTokenExchangeResponse_shouldContainIdJagFields() { + JSONObject response = idJagService.buildTokenExchangeResponse("signed.jwt.here"); + + assertEquals("signed.jwt.here", response.getString("access_token")); + assertEquals(TOKEN_TYPE_ID_JAG, response.getString("issued_token_type")); + assertEquals("N_A", response.getString("token_type")); + } +}