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));
+  }
+}

Reply via email to