This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit 5e7d83f8863234d071d58bf1231c2ba16ea16e36 Author: Sandor Molnar <[email protected]> AuthorDate: Wed Aug 12 00:17:30 2026 +0200 KNOX-3414: emit structured audit records across OAuth2/OIDC endpoints (J2) Add audit-log completeness for the KnoxIDF authorization server. Every security-relevant decision on the Authorize, Token, Registration and UserInfo endpoints is now recorded through Knox's existing Auditor/ AuditService framework with a consistent action/outcome/resource shape: - AuthorizeResource: authorization request rejected, authorization code issued / issuance failed, consent granted / denied / invalid-state, and the federated-OP callback outcome (single try/finally over all exits). - TokenResource: authorization_code and refresh_token grant outcomes (issued/rotated vs. replayed/validation-failed) and unsupported grant. - RegistrationResource: client-registration outcome incl. anonymous-denied and invalid_request reasons. - UserInfoResource: /userinfo access outcome incl. invalid_token and unknown-federated-identity reasons. Emission is centralized in a new KnoxIDFAudit holder so all records share one Auditor, a single masking rule and a consistent shape. Credentials and full tokens are NEVER logged: token ids/JWTs pass through mask() (prefix/ suffix only) and client_secret/code_verifier/raw refresh tokens are never recorded. The Auditor field is package-private/non-final for test injection, mirroring TrustedOidcIssuersResource. Covered by KnoxIDFAuditTest: a representative SUCCESS (rotated refresh grant) and FAILURE (unsupported grant) assert the emitted record, plus the mask() never-leaks invariant. Full knoxidf suite: 88/88 green. Co-Authored-By: Claude Opus 4.8 <[email protected]> --- .../gateway/service/knoxidf/AuthorizeResource.java | 140 +++++++---- .../knox/gateway/service/knoxidf/KnoxIDFAudit.java | 91 +++++++ .../service/knoxidf/RegistrationResource.java | 59 +++-- .../gateway/service/knoxidf/TokenResource.java | 77 ++++-- .../gateway/service/knoxidf/UserInfoResource.java | 113 +++++---- .../gateway/service/knoxidf/KnoxIDFAuditTest.java | 274 +++++++++++++++++++++ 6 files changed, 622 insertions(+), 132 deletions(-) diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java index 84c213c95..d2a12b581 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java @@ -33,6 +33,9 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; import org.apache.knox.gateway.security.SubjectUtils; import org.apache.knox.gateway.service.knoxtoken.PasscodeTokenResourceBase; import org.apache.knox.gateway.services.GatewayServices; @@ -182,6 +185,11 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { final AuthorizeRequestMetadata authorizeRequestMetadata = new AuthorizeRequestMetadata(clientId, subject, responseType, redirectUri, requestedScopes, state, nonce, codeChallenge, codeChallengeMethod); final Response verificationErrorResponse = verifyParams(authorizeRequestMetadata); if (verificationErrorResponse != null) { + // The authorization request was rejected (unknown client_id, bad redirect_uri, disallowed + // scope, or unsupported PKCE method). Record the rejection with the masked client_id. + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=authorize subject=" + KnoxIDFAudit.subjectLabel(subject) + + " reason=request_rejected"); return verificationErrorResponse; } @@ -252,13 +260,22 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { } private Response getAuthCodeFromKnox(final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair<String, String> federatedTokens) { + final String clientId = authorizeRequestMetadata.getClientId(); + final String subject = KnoxIDFAudit.subjectLabel(authorizeRequestMetadata.getSubject()); final Response tokenResponse = getAuthenticationToken(); if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) { final Map<String, String> tokenResponseMap = JsonUtils.getMapFromJsonString(tokenResponse.getEntity().toString()); final String tokenId = tokenResponseMap.get(TOKEN_ID); decorateAuthCodeToken(tokenId, authorizeRequestMetadata, federatedTokens); + // An authorization code was issued to the client for this subject. The code is masked; + // it is a single-use credential and its full value must never appear in the audit log. + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + ActionOutcome.SUCCESS, "event=authorize subject=" + subject + " code=" + KnoxIDFAudit.mask(tokenId) + + (federatedTokens == null ? "" : " federated=true") + " reason=code_issued"); return redirectToAuthSuccess(authorizeRequestMetadata, tokenId); } + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=authorize subject=" + subject + " reason=code_issuance_failed"); return tokenResponse; } @@ -293,54 +310,75 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { //This is the callback for the federated OP final String federatedAuthCode = request.getParameter(CODE); final String state = request.getParameter(STATE); - if (StringUtils.isBlank(state) || StringUtils.isBlank(federatedAuthCode)) { - return error("invalid_request", "Missing state or code"); - } - final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state); - if (authorizeRequestMetadata == null) { - return error("invalid_request", "Unknown or expired state"); - } - final Set<FederatedOpConfiguration> opConfigs = federatedOpConfigurationStore.get(state); - final FederatedOpConfiguration federatedOpConfiguration = opConfigs == null ? null : opConfigs.stream().findFirst().orElse(null); - if (federatedOpConfiguration == null) { - return error("invalid_request", "No federated OP configuration available for the request"); - } - // The federated callback state is single-use: invalidate it in both stores now that it has - // been validated and captured, so a replayed callback with the same state is rejected. The - // nonce Knox sent to the OP was stashed under the same key (the login-session id == state); - // retrieve and invalidate it too so it cannot be reused. - authorizeRequestMetadataStore.remove(state); - federatedOpConfigurationStore.remove(state); - final String expectedNonce = federatedNonceStore.get(state); - federatedNonceStore.remove(state); - final Pair<String, String> federatedTokens; + // Audit the federated-OP login callback exactly once. The resource is the federated OP name + // (once known); the reason distinguishes the failure modes. The federated auth code and the + // OP tokens are never logged. + String opName = KnoxIDFAudit.UNKNOWN; + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; try { - federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration); - } catch (ClientSecretResolutionException e) { - // A configured client-secret alias could not be resolved. This is a server-side - // misconfiguration, not a client error, and we deliberately never made the OP call. - return error("server_error", e.getMessage()); - } - if (StringUtils.isBlank(federatedTokens.getLeft())) { - return error("invalid_request", "Federated OP did not return an id_token"); - } - final JWT federatedIdToken = new JWTToken(federatedTokens.getLeft()); - // Verify the OP's id_token (signature/issuer/audience/expiry) before trusting any claim in it. - final Response validationError = validateFederatedIdToken(federatedIdToken, federatedOpConfiguration); - if (validationError != null) { - return validationError; - } - // Bind the (now signature-verified) id_token to this authorization request (OIDC Core 3.1.2.1): - // its nonce claim must equal the nonce Knox generated and sent to the OP for this login session. - // This is checked only after the token's authenticity is established, so a forged token cannot - // supply its own matching nonce. A missing expected nonce (e.g. expired/replayed state) or a - // mismatch fails the flow. - final Response nonceError = verifyFederatedNonce(expectedNonce, federatedIdToken); - if (nonceError != null) { - return nonceError; + if (StringUtils.isBlank(state) || StringUtils.isBlank(federatedAuthCode)) { + detail = "reason=missing_state_or_code"; + return error("invalid_request", "Missing state or code"); + } + final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state); + if (authorizeRequestMetadata == null) { + detail = "reason=unknown_state"; + return error("invalid_request", "Unknown or expired state"); + } + final Set<FederatedOpConfiguration> opConfigs = federatedOpConfigurationStore.get(state); + final FederatedOpConfiguration federatedOpConfiguration = opConfigs == null ? null : opConfigs.stream().findFirst().orElse(null); + if (federatedOpConfiguration == null) { + detail = "reason=no_op_configuration"; + return error("invalid_request", "No federated OP configuration available for the request"); + } + opName = federatedOpConfiguration.getName(); + // The federated callback state is single-use: invalidate it in both stores now that it has + // been validated and captured, so a replayed callback with the same state is rejected. The + // nonce Knox sent to the OP was stashed under the same key (the login-session id == state); + // retrieve and invalidate it too so it cannot be reused. + authorizeRequestMetadataStore.remove(state); + federatedOpConfigurationStore.remove(state); + final String expectedNonce = federatedNonceStore.get(state); + federatedNonceStore.remove(state); + final Pair<String, String> federatedTokens; + try { + federatedTokens = exchangeFederatedAuthCodeToTokens(federatedAuthCode, federatedOpConfiguration); + } catch (ClientSecretResolutionException e) { + // A configured client-secret alias could not be resolved. This is a server-side + // misconfiguration, not a client error, and we deliberately never made the OP call. + detail = "reason=client_secret_unresolved"; + return error("server_error", e.getMessage()); + } + if (StringUtils.isBlank(federatedTokens.getLeft())) { + detail = "reason=no_id_token"; + return error("invalid_request", "Federated OP did not return an id_token"); + } + final JWT federatedIdToken = new JWTToken(federatedTokens.getLeft()); + // Verify the OP's id_token (signature/issuer/audience/expiry) before trusting any claim in it. + final Response validationError = validateFederatedIdToken(federatedIdToken, federatedOpConfiguration); + if (validationError != null) { + detail = "reason=id_token_validation_failed"; + return validationError; + } + // Bind the (now signature-verified) id_token to this authorization request (OIDC Core 3.1.2.1): + // its nonce claim must equal the nonce Knox generated and sent to the OP for this login session. + // This is checked only after the token's authenticity is established, so a forged token cannot + // supply its own matching nonce. A missing expected nonce (e.g. expired/replayed state) or a + // mismatch fails the flow. + final Response nonceError = verifyFederatedNonce(expectedNonce, federatedIdToken); + if (nonceError != null) { + detail = "reason=nonce_mismatch"; + return nonceError; + } + final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName()); + outcome = ActionOutcome.SUCCESS; + detail = "reason=federated_identity_resolved subject=" + KnoxIDFAudit.subjectLabel(federatedIdentity.getId()); + return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight())); + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, opName, ResourceType.TRUSTED_ISSUER, outcome, + "event=federated_callback op=" + opName + " " + detail); } - final FederatedIdentity federatedIdentity = resolveFederatedIdentity(federatedIdToken, federatedOpConfiguration.getName()); - return getAuthCodeFromKnox(authorizeRequestMetadata, Pair.of(federatedIdentity.getId(), federatedTokens.getRight())); } @GET @@ -349,11 +387,18 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { final String state = request.getParameter(STATE); final AuthorizeRequestMetadata authorizeRequestMetadata = authorizeRequestMetadataStore.get(state); if (authorizeRequestMetadata == null) { + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.UNKNOWN, ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=consent reason=invalid_or_expired_state"); return error("invalid_request", "Invalid state"); } // Single-use consent state: invalidate it so the accepted-consent redirect cannot be replayed. authorizeRequestMetadataStore.remove(state); markConsentAccepted(authorizeRequestMetadata); + // Consent was granted by the subject for this client; the ensuing authorize() call audits the + // resulting code issuance separately. + KnoxIDFAudit.audit(Action.AUTHORIZATION, KnoxIDFAudit.mask(authorizeRequestMetadata.getClientId()), + ResourceType.PRINCIPAL, ActionOutcome.SUCCESS, "event=consent subject=" + + KnoxIDFAudit.subjectLabel(authorizeRequestMetadata.getSubject()) + " reason=consent_granted"); return authorize(authorizeRequestMetadata.getResponseType(), authorizeRequestMetadata.getClientId(), authorizeRequestMetadata.getRedirectUri(), @@ -367,6 +412,9 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { @GET @Path("/consentDenied") public Response consentDenied() throws Exception { + KnoxIDFAudit.audit(Action.AUTHORIZATION, + KnoxIDFAudit.subjectLabel(SubjectUtils.getCurrentEffectivePrincipalName()), ResourceType.PRINCIPAL, + ActionOutcome.FAILURE, "event=consent reason=consent_denied"); return Response.status(Response.Status.FORBIDDEN).entity("Consent denied!").build(); } diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java new file mode 100644 index 000000000..62b49cdf2 --- /dev/null +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAudit.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.service.knoxidf; + +import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.audit.api.AuditServiceFactory; +import org.apache.knox.gateway.audit.api.Auditor; +import org.apache.knox.gateway.audit.log4j.audit.AuditConstants; +import org.apache.knox.gateway.util.Tokens; + +/** + * Centralized audit emission for the KnoxIDF OAuth2/OIDC endpoints + * ({@link AuthorizeResource}, {@link TokenResource}, {@link RegistrationResource}, + * {@link UserInfoResource}). + * <p> + * Every security-relevant decision on these endpoints — an authorization request accepted or + * rejected, consent shown/granted/denied, an authorization code issued, a code or refresh token + * redeemed or replayed, a client registered, a federated callback validated, user info served — is + * recorded through this class so the records share a single {@link Auditor} instance, a consistent + * action/outcome/resource shape and, crucially, a single masking rule: credentials and full tokens + * are NEVER written to the audit log. Token identifiers and JWTs are always passed through + * {@link #mask(String)} first, and {@code client_secret}/{@code code_verifier}/raw refresh tokens + * are never logged at all. + * <p> + * The {@link Auditor} field mirrors {@link TrustedOidcIssuersResource}: it is package-private and + * non-final so a unit test can inject a capturing mock and assert the emitted record. + */ +final class KnoxIDFAudit { + + /** Placeholder used when a resource/subject identifier is absent or cannot be masked. */ + static final String UNKNOWN = "UNKNOWN"; + + /** Placeholder for an unauthenticated caller. */ + static final String ANONYMOUS = "ANONYMOUS"; + + // Non-final and package-private to allow test injection of a mock Auditor (see the sibling + // TrustedOidcIssuersResource, which uses the same idiom). + static Auditor auditor = AuditServiceFactory.getAuditService() + .getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME, + AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME); + + private KnoxIDFAudit() { + } + + /** + * Emits an audit record via the shared {@link Auditor}. The {@code resource} is used verbatim, so + * callers that pass a token identifier or JWT MUST first mask it with {@link #mask(String)}. A + * blank {@code resource} is normalized to {@link #UNKNOWN} because the underlying auditor rejects a + * null resource name. + */ + static void audit(final String action, final String resource, final String resourceType, + final String outcome, final String message) { + auditor.audit(action, StringUtils.isBlank(resource) ? UNKNOWN : resource, resourceType, outcome, message); + } + + /** + * Masks a token or token identifier for safe logging. A Knox token UUID is rendered via + * {@link Tokens#getTokenIDDisplayText(String)} and a JWT via {@link Tokens#getTokenDisplayText(String)}; + * both keep only a short prefix/suffix so the full secret never reaches the log. Returns + * {@link #UNKNOWN} for a blank or unmaskable value. This method never returns the raw input. + */ + static String mask(final String tokenOrId) { + if (StringUtils.isBlank(tokenOrId)) { + return UNKNOWN; + } + String display = Tokens.getTokenIDDisplayText(tokenOrId); + if (display == null) { + display = Tokens.getTokenDisplayText(tokenOrId); + } + return display == null ? UNKNOWN : display; + } + + /** Renders a subject/principal name for logging, mapping a blank/absent principal to {@link #ANONYMOUS}. */ + static String subjectLabel(final String subject) { + return StringUtils.isBlank(subject) ? ANONYMOUS : subject; + } +} diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java index bd7dedff2..6cc02cd37 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/RegistrationResource.java @@ -18,6 +18,9 @@ package org.apache.knox.gateway.service.knoxidf; import com.nimbusds.jose.KeyLengthException; import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; import org.apache.knox.gateway.security.SubjectUtils; import org.apache.knox.gateway.service.knoxtoken.ClientCredentialsResource; import org.apache.knox.gateway.services.ServiceLifecycleException; @@ -88,28 +91,46 @@ public class RegistrationResource extends ClientCredentialsResource { @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response registerClient(@FormParam("redirect_uris") String redirectUris, @FormParam("allowed_scopes") String allowedScopes) { - if (anonymousRegistrationDenied()) { - return error("access_denied", "Anonymous client registration is disabled. Set '" - + CLIENT_REGISTRATION_ANONYMOUS_ALLOWED + "' to true in the KNOXIDF service configuration to enable it."); - } - if (StringUtils.isBlank(redirectUris)) { - return error("invalid_request", "redirect_uris must be provided"); - } - this.redirectUris = Arrays.asList(redirectUris.split(",")); - final Response redirectUriVerificationResponse = verifyRedirectUris(); - if (redirectUriVerificationResponse != null) { - return redirectUriVerificationResponse; - } + // Audit the outcome of every dynamic client-registration attempt exactly once, recording the + // caller principal and the reason for a rejection. No secret (the minted client_secret) is + // ever logged — only that a client was registered. + final String caller = KnoxIDFAudit.subjectLabel(SubjectUtils.getCurrentEffectivePrincipalName()); + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; + try { + if (anonymousRegistrationDenied()) { + detail = "reason=anonymous_denied"; + return error("access_denied", "Anonymous client registration is disabled. Set '" + + CLIENT_REGISTRATION_ANONYMOUS_ALLOWED + "' to true in the KNOXIDF service configuration to enable it."); + } + if (StringUtils.isBlank(redirectUris)) { + detail = "reason=missing_redirect_uris"; + return error("invalid_request", "redirect_uris must be provided"); + } + this.redirectUris = Arrays.asList(redirectUris.split(",")); + final Response redirectUriVerificationResponse = verifyRedirectUris(); + if (redirectUriVerificationResponse != null) { + detail = "reason=invalid_redirect_uris"; + return redirectUriVerificationResponse; + } - if (StringUtils.isBlank(allowedScopes)) { - this.allowedScopes = new ArrayList<>(DEFAULT_SCOPES); - } else { - this.allowedScopes = Arrays.asList(allowedScopes.split(",")); - if (!this.allowedScopes.contains("openid")) { - return error("invalid_request", "allowed_scopes must include 'openid'"); + if (StringUtils.isBlank(allowedScopes)) { + this.allowedScopes = new ArrayList<>(DEFAULT_SCOPES); + } else { + this.allowedScopes = Arrays.asList(allowedScopes.split(",")); + if (!this.allowedScopes.contains("openid")) { + detail = "reason=invalid_scope"; + return error("invalid_request", "allowed_scopes must include 'openid'"); + } } + final Response response = super.doPost(); + outcome = ActionOutcome.SUCCESS; + detail = "reason=client_registered"; + return response; + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, caller, ResourceType.PRINCIPAL, outcome, + "event=client_registration " + detail); } - return super.doPost(); } /** diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java index c0c71a452..c414f6b1a 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java @@ -18,6 +18,9 @@ package org.apache.knox.gateway.service.knoxidf; import com.nimbusds.jose.KeyLengthException; import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; import org.apache.knox.gateway.config.GatewayConfig; import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory; @@ -142,6 +145,9 @@ public class TokenResource extends PasscodeTokenResourceBase { } else if (AUTH_CODE.equals(grantType)) { return handleAuthorizationCodeFlow(); } + KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(getRequestParam(CLIENT_ID)), + ResourceType.PRINCIPAL, ActionOutcome.FAILURE, + "event=token_grant grant_type=" + grantType + " reason=unsupported_grant_type"); return error("invalid_request", "invalid grant type: " + grantType); } @@ -207,9 +213,17 @@ public class TokenResource extends PasscodeTokenResourceBase { // Package-private for testability (the single-use rotation guard is exercised by // TokenResourceRefreshTokenRotationTest); not part of the public resource API. Response handleRefreshToken() { + // Audit the outcome of every refresh_token grant exactly once. The resource is the masked + // client_id; the masked refresh-token id and a reason are recorded in the message. The raw + // refresh token and client_secret are never logged. + final String clientId = getRequestParam(CLIENT_ID); + String maskedRefreshTokenId = KnoxIDFAudit.UNKNOWN; + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; try { final String refreshTokenParam = getRequestParam(REFRESH_TOKEN); final String refreshTokenId = TokenUtils.getTokenId(refreshTokenParam); + maskedRefreshTokenId = KnoxIDFAudit.mask(refreshTokenId); final TokenMetadata refreshTokenMetadata = tokenStateService.getTokenMetadata(refreshTokenId); validateRefreshTokenGrant(refreshTokenParam, refreshTokenId, refreshTokenMetadata); @@ -221,6 +235,7 @@ public class TokenResource extends PasscodeTokenResourceBase { // request already redeemed/rotated this token, so reject it as invalid_grant. This mirrors // the consume-before-issue guard on the authorization_code grant (see handleAuthorizationCodeFlow). if (!tokenStateService.consumeToken(refreshTokenId)) { + detail = "reason=refresh_token_replayed"; return error("invalid_grant", "Refresh token has already been redeemed"); } @@ -233,15 +248,23 @@ public class TokenResource extends PasscodeTokenResourceBase { // Build new tokens final UserContext userContext = new UserContext(userName, null, userParams); final TokenResponseContext resp = getTokenResponse(userContext); + outcome = ActionOutcome.SUCCESS; + detail = "reason=rotated"; return resp.build(); } catch (ParseException e) { + detail = "reason=malformed_refresh_token"; return error("invalid_grant", "Malformed refresh_token"); } catch (UnknownTokenException e) { + detail = "reason=unknown_refresh_token"; return error("invalid_grant", "Unknown refresh_token"); } catch (RefreshTokenValidationError e) { + detail = "reason=validation_failed"; return error("invalid_grant", e.getMessage()); + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + outcome, "event=token_grant grant_type=refresh_token refresh_token_id=" + + maskedRefreshTokenId + " " + detail); } - } // Package-private for testability (client-authentication on the refresh grant is exercised by @@ -292,27 +315,43 @@ public class TokenResource extends PasscodeTokenResourceBase { Response handleAuthorizationCodeFlow() { final String code = getRequestParam(CODE); final String redirectUri = getRequestParam(REDIRECT_URI); - - final TokenMetadata authCodeMetadata; + // Audit the outcome of every authorization_code grant exactly once. The resource is the masked + // client_id; the masked auth-code id and a reason are recorded in the message. The raw code, + // code_verifier and client_secret are never logged. + final String clientId = getRequestParam(CLIENT_ID); + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; try { - authCodeMetadata = validateAuthCode(code, redirectUri); - } catch (AuthTokenValidationError e) { - return error("invalid_grant", e.getMessage()); - } + final TokenMetadata authCodeMetadata; + try { + authCodeMetadata = validateAuthCode(code, redirectUri); + } catch (AuthTokenValidationError e) { + detail = "reason=validation_failed"; + return error("invalid_grant", e.getMessage()); + } - // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent - // redemptions of the same code, exactly one wins the consume and proceeds; the losers get - // invalid_grant. This closes the replay window that existed when the code was only revoked - // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT - // consumed here, so replaying with bad params cannot burn a victim's still-valid code. - if (!tokenStateService.consumeToken(code)) { - return error("invalid_grant", "Authorization code has already been redeemed"); - } + // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent + // redemptions of the same code, exactly one wins the consume and proceeds; the losers get + // invalid_grant. This closes the replay window that existed when the code was only revoked + // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT + // consumed here, so replaying with bad params cannot burn a victim's still-valid code. + if (!tokenStateService.consumeToken(code)) { + detail = "reason=code_replayed"; + return error("invalid_grant", "Authorization code has already been redeemed"); + } - // The code is now gone from the store; hand the already-validated metadata to the issuance - // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code. - request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata); - return getAuthenticationToken(); + // The code is now gone from the store; hand the already-validated metadata to the issuance + // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code. + request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata); + final Response response = getAuthenticationToken(); + outcome = ActionOutcome.SUCCESS; + detail = "reason=tokens_issued"; + return response; + } finally { + KnoxIDFAudit.audit(Action.AUTHENTICATION, KnoxIDFAudit.mask(clientId), ResourceType.PRINCIPAL, + outcome, "event=token_grant grant_type=authorization_code code=" + KnoxIDFAudit.mask(code) + + " " + detail); + } } /** diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java index f500d6d57..320f79785 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/UserInfoResource.java @@ -18,6 +18,9 @@ package org.apache.knox.gateway.service.knoxidf; import org.apache.commons.lang3.StringUtils; +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.ResourceType; import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProviderFactory; import org.apache.knox.gateway.services.GatewayServices; @@ -82,59 +85,73 @@ public class UserInfoResource { @Produces(MediaType.APPLICATION_JSON) public Response getUserInfo() { final String tokenId = request.getAttribute(TOKEN_ID_ATTRIBUTE) == null ? null : request.getAttribute(TOKEN_ID_ATTRIBUTE).toString(); - if (tokenId == null) { - return error("invalid_request", "Cannot find tokenId"); - } - - final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString(); - final TokenMetadata tokenMetadata; + // Audit the outcome of every /userinfo access exactly once. The resource is the masked + // bearer-token id (never the raw token); the reason distinguishes the failure modes. + String outcome = ActionOutcome.FAILURE; + String detail = "reason=unknown"; try { - tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId); - } catch (UnknownTokenException e) { - // Expired, revoked, or otherwise unknown bearer token. Per RFC 6750 the protected - // resource must answer 401 with a WWW-Authenticate: Bearer error="invalid_token" - // challenge rather than leaking a 500 for what is a client authentication failure. - return invalidToken("The access token is expired, revoked, or unknown"); - } - final Map<String, Object> userInfo = new HashMap<>(); - - // Check if this token has a federated identity - final String federatedIdentityId = tokenMetadata.getMetadata("federated_identity_id"); - - if (StringUtils.isNotBlank(federatedIdentityId)) { - // Federated user - final FederatedIdentity federatedIdentity = federatedIdentityService - .findById(federatedIdentityId) - .orElse(null); - if (federatedIdentity == null) { - // The token references a federated identity that no longer exists; the bearer token - // can no longer be honored, so answer with the RFC 6750 invalid_token challenge. - return invalidToken("The access token references an unknown federated identity"); + if (tokenId == null) { + detail = "reason=missing_token_id"; + return error("invalid_request", "Cannot find tokenId"); } - // Include only allowed claims - Map<String, Object> claims = federatedIdentity.getAttributes().entrySet().stream() - .filter(e -> AuthorizeResource.ALLOWED_CLAIMS.contains(e.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - // Mandatory claims for OIDC - claims.put("sub", federatedIdentity.getUserId()); // internal Knox subject - claims.put("idp", federatedIdentity.getProvider()); - - // Optional: federated info for auditing - claims.put("federated_sub", federatedIdentity.getExternalSubject()); - claims.put("federated_iss", federatedIdentity.getExternalIssuer()); - - // Note: nonce is deliberately NOT returned here. Per OIDC it belongs in the id_token - // only; echoing it from the UserInfo endpoint is a spec violation and serves no purpose. + final String scope = request.getAttribute(SCOPE_ATTRIBUTE) == null ? "" : request.getAttribute(SCOPE_ATTRIBUTE).toString(); + final TokenMetadata tokenMetadata; + try { + tokenMetadata = getReadonlyTokenStateService().getTokenMetadata(tokenId); + } catch (UnknownTokenException e) { + // Expired, revoked, or otherwise unknown bearer token. Per RFC 6750 the protected + // resource must answer 401 with a WWW-Authenticate: Bearer error="invalid_token" + // challenge rather than leaking a 500 for what is a client authentication failure. + detail = "reason=invalid_token"; + return invalidToken("The access token is expired, revoked, or unknown"); + } + final Map<String, Object> userInfo = new HashMap<>(); + + // Check if this token has a federated identity + final String federatedIdentityId = tokenMetadata.getMetadata("federated_identity_id"); + + if (StringUtils.isNotBlank(federatedIdentityId)) { + // Federated user + final FederatedIdentity federatedIdentity = federatedIdentityService + .findById(federatedIdentityId) + .orElse(null); + if (federatedIdentity == null) { + // The token references a federated identity that no longer exists; the bearer token + // can no longer be honored, so answer with the RFC 6750 invalid_token challenge. + detail = "reason=unknown_federated_identity"; + return invalidToken("The access token references an unknown federated identity"); + } + + // Include only allowed claims + Map<String, Object> claims = federatedIdentity.getAttributes().entrySet().stream() + .filter(e -> AuthorizeResource.ALLOWED_CLAIMS.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + // Mandatory claims for OIDC + claims.put("sub", federatedIdentity.getUserId()); // internal Knox subject + claims.put("idp", federatedIdentity.getProvider()); + + // Optional: federated info for auditing + claims.put("federated_sub", federatedIdentity.getExternalSubject()); + claims.put("federated_iss", federatedIdentity.getExternalIssuer()); + + // Note: nonce is deliberately NOT returned here. Per OIDC it belongs in the id_token + // only; echoing it from the UserInfo endpoint is a spec violation and serves no purpose. + + userInfo.putAll(claims); + } else { + // Local Knox user + userInfo.putAll(userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope)); + } - userInfo.putAll(claims); - } else { - // Local Knox user - userInfo.putAll(userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope)); + outcome = ActionOutcome.SUCCESS; + detail = "reason=served"; + return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build(); + } finally { + KnoxIDFAudit.audit(Action.ACCESS, KnoxIDFAudit.mask(tokenId), ResourceType.URI, outcome, + "event=userinfo " + detail); } - - return Response.ok(JsonUtils.renderAsJsonString(userInfo, true)).build(); } TokenStateService getReadonlyTokenStateService() { diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java new file mode 100644 index 000000000..f56edbc5e --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/KnoxIDFAuditTest.java @@ -0,0 +1,274 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.service.knoxidf; + +import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.security.CommonTokenConstants.GRANT_TYPE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REFRESH_TOKEN; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.audit.api.Action; +import org.apache.knox.gateway.audit.api.ActionOutcome; +import org.apache.knox.gateway.audit.api.Auditor; +import org.apache.knox.gateway.audit.api.CorrelationContext; +import org.apache.knox.gateway.audit.api.AuditContext; +import org.apache.knox.gateway.audit.api.ResourceType; +import org.apache.knox.gateway.service.knoxidf.userparams.UserParamsProvider; +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenMetadataType; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Representative coverage for the KnoxIDF audit instrumentation (structured audit-log completeness). + * A capturing {@link Auditor} is injected into {@link KnoxIDFAudit#auditor} so the emitted + * action/outcome/resource/message can be asserted for a representative SUCCESS path (a rotated + * refresh-token grant) and a representative FAILURE path (an unsupported grant type). It also pins + * the security-critical invariant that {@link KnoxIDFAudit#mask(String)} never echoes a raw secret + * into the record. + */ +public class KnoxIDFAuditTest { + + // A UUID so TokenUtils.getTokenId returns it verbatim (no JWT parsing needed). + private static final String REFRESH_TOKEN_ID = "11111111-2222-3333-4444-555555555555"; + private static final String CLIENT = "client-abc"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + /** Records every thread-local 5-arg audit call so a test can assert what was emitted. */ + static final class CapturingAuditor implements Auditor { + static final class Record { + final String action; + final String resource; + final String resourceType; + final String outcome; + final String message; + + Record(String action, String resource, String resourceType, String outcome, String message) { + this.action = action; + this.resource = resource; + this.resourceType = resourceType; + this.outcome = outcome; + this.message = message; + } + } + + final List<Record> records = new ArrayList<>(); + + @Override + public void audit(String action, String resourceName, String resourceType, String outcome, String message) { + records.add(new Record(action, resourceName, resourceType, outcome, message)); + } + + @Override + public void audit(String action, String resourceName, String resourceType, String outcome) { + audit(action, resourceName, resourceType, outcome, null); + } + + @Override + public void audit(CorrelationContext correlationContext, AuditContext auditContext, String action, + String resourceName, String resourceType, String outcome, String message) { + audit(action, resourceName, resourceType, outcome, message); + } + + @Override + public String getServiceName() { + return "knox"; + } + + @Override + public String getComponentName() { + return "knox"; + } + + @Override + public String getAuditorName() { + return "audit"; + } + } + + private static final Auditor ORIGINAL_AUDITOR = KnoxIDFAudit.auditor; + private CapturingAuditor capturingAuditor; + + @Before + public void setUp() { + capturingAuditor = new CapturingAuditor(); + KnoxIDFAudit.auditor = capturingAuditor; + } + + @After + public void tearDown() { + KnoxIDFAudit.auditor = ORIGINAL_AUDITOR; + } + + // --------------------------------------------------------------------------- + // mask(): never echoes a raw secret; blank/unmaskable -> UNKNOWN + // --------------------------------------------------------------------------- + + @Test + public void testMaskNeverLeaksRawValue() { + final String secret = "supersecret-client-secret-value-1234567890"; + final String masked = KnoxIDFAudit.mask(secret); + assertNotNull(masked); + assertFalse("mask() must never return the raw secret", secret.equals(masked)); + assertFalse("mask() output must not contain the full raw secret", masked.contains(secret)); + assertTrue("mask() output must be shorter than the raw secret", masked.length() < secret.length()); + } + + @Test + public void testMaskBlankOrTooShortIsUnknown() { + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask(null)); + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask("")); + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask(" ")); + // Too short for Tokens display text -> normalized to UNKNOWN, never the raw value. + assertEquals(KnoxIDFAudit.UNKNOWN, KnoxIDFAudit.mask("abc")); + } + + // --------------------------------------------------------------------------- + // Representative FAILURE: unsupported grant type on the token endpoint + // --------------------------------------------------------------------------- + + @Test + public void testUnsupportedGrantTypeEmitsFailureAudit() { + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(GRANT_TYPE)).andReturn("password").anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.replay(req); + + final TokenResource resource = new TokenResource(); + resource.request = req; + + final Response response = resource.doPost(); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals("Exactly one audit record must be emitted.", 1, capturingAuditor.records.size()); + final CapturingAuditor.Record record = capturingAuditor.records.get(0); + assertEquals(Action.AUTHENTICATION, record.action); + assertEquals(ResourceType.PRINCIPAL, record.resourceType); + assertEquals(ActionOutcome.FAILURE, record.outcome); + assertTrue(record.message.contains("reason=unsupported_grant_type")); + assertTrue(record.message.contains("grant_type=password")); + // The client_id is masked, never emitted verbatim. + assertFalse("client_id must be masked in the audit record", CLIENT.equals(record.resource)); + } + + // --------------------------------------------------------------------------- + // Representative SUCCESS: a rotated refresh-token grant + // --------------------------------------------------------------------------- + + /** Field injection plus a stub for the token-mint step so only the audit emission is under test. */ + private static final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req, + final UserParamsProvider userParamsProvider) throws Exception { + this.tokenStateService = tss; + this.tokenMAC = mac; + this.request = req; + // userParamsProvider is private on TokenResource and normally wired in init(); inject it + // directly so the rotation success path can build its UserContext without a servlet context. + final Field field = TokenResource.class.getDeclaredField("userParamsProvider"); + field.setAccessible(true); + field.set(this, userParamsProvider); + } + + @Override + protected TokenResponseContext getTokenResponse(final UserContext context) { + return new TokenResponseContext(null, "issued", Response.ok()); + } + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testRefreshTokenRotationEmitsSuccessAudit() throws Exception { + final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + final TokenMetadata refreshTokenMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(refreshTokenMetadata.getType()).andReturn(TokenMetadataType.REFRESH_TOKEN.name()).anyTimes(); + EasyMock.expect(refreshTokenMetadata.isEnabled()).andReturn(true).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(refreshTokenMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.replay(refreshTokenMetadata); + + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + final TokenStateService tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(REFRESH_TOKEN_ID)).andReturn(refreshTokenMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenExpiration(REFRESH_TOKEN_ID)) + .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)).anyTimes(); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes(); + // This redemption wins the atomic consume and rotates. + EasyMock.expect(tokenStateService.consumeToken(REFRESH_TOKEN_ID)).andReturn(true).once(); + EasyMock.replay(tokenStateService); + + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(REFRESH_TOKEN)).andReturn(REFRESH_TOKEN_ID).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes(); + EasyMock.replay(req); + + final UserParamsProvider userParamsProvider = EasyMock.createNiceMock(UserParamsProvider.class); + EasyMock.expect(userParamsProvider.getParamsFor(EasyMock.anyString(), EasyMock.anyObject())) + .andReturn(new HashMap<>()).anyTimes(); + EasyMock.replay(userParamsProvider); + + final TestableTokenResource resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC, req, userParamsProvider); + + final Response response = resource.handleRefreshToken(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("Exactly one audit record must be emitted.", 1, capturingAuditor.records.size()); + final CapturingAuditor.Record record = capturingAuditor.records.get(0); + assertEquals(Action.AUTHENTICATION, record.action); + assertEquals(ResourceType.PRINCIPAL, record.resourceType); + assertEquals(ActionOutcome.SUCCESS, record.outcome); + assertTrue(record.message.contains("grant_type=refresh_token")); + assertTrue(record.message.contains("reason=rotated")); + // Neither the raw refresh token id nor the client_id appear verbatim. + assertFalse(record.message.contains(REFRESH_TOKEN_ID)); + assertFalse("client_id must be masked in the audit record", CLIENT.equals(record.resource)); + } +}
