You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@knox.apache.org by ha...@apache.org on 2026/09/17 12:36:31 UTC

(knox) branch master updated: KNOX-3471: KnoxIDF expires_in should return relative second instead o… (#1408)

This is an automated email from the ASF dual-hosted git repository.

hanicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/master by this push:
     new 1bebbfa2d KNOX-3471: KnoxIDF expires_in should return relative second instead o… (#1408)
1bebbfa2d is described below

commit 1bebbfa2d97a6b75f61a029ad37ec7facee653b9
Author: hanicz <ha...@users.noreply.github.com>
AuthorDate: Thu Sep 17 14:36:26 2026 +0200

    KNOX-3471: KnoxIDF expires_in should return relative second instead o… (#1408)
    
    * KNOX-3471: KnoxIDF expires_in should return relative second instead of absolute epoch-ms
    
    * KNOX-3471: Remove expires_in if there is no expiry
---
 .github/workflows/tests/test_knoxidf.py            |  11 ++
 .../gateway/service/knoxidf/TokenResource.java     |  13 +++
 .../knoxidf/TokenResourceExpiresInTest.java        | 120 +++++++++++++++++++++
 knox-site/docs/knoxidf/endpoints.md                |   2 +-
 4 files changed, 145 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/tests/test_knoxidf.py b/.github/workflows/tests/test_knoxidf.py
index 4a565cc54..033708698 100644
--- a/.github/workflows/tests/test_knoxidf.py
+++ b/.github/workflows/tests/test_knoxidf.py
@@ -32,6 +32,8 @@ from common_utils import (
 class TestKnoxIDF(unittest.TestCase):
     """OIDC provider tests covering discovery, client credentials, and auth code flows."""
 
+    EXPECTED_TOKEN_TTL_SECONDS = 86400
+
     def setUp(self):
         # Get the Knox Gateway URL from environment variables
         self.base_url = gateway_base_url()
@@ -40,6 +42,12 @@ class TestKnoxIDF(unittest.TestCase):
         self.username = "guest"
         self.password = "guest-password"
 
+    def _assert_expires_in_relative_seconds(self, tokens):
+        self.assertIn("expires_in", tokens)
+        expires_in = tokens["expires_in"]
+        self.assertIsInstance(expires_in, int)
+        self.assertEqual(expires_in, self.EXPECTED_TOKEN_TTL_SECONDS)
+
     def test_discovery(self):
         """
         Test OIDC Discovery endpoint.
@@ -105,6 +113,7 @@ class TestKnoxIDF(unittest.TestCase):
         self.assertIn("access_token", tokens)
         self.assertEqual(tokens["token_type"], "Bearer")
         self.assertEqual(tokens["issued_token_type"], "urn:ietf:params:oauth:token-type:jwt")
+        self._assert_expires_in_relative_seconds(tokens)
 
     def test_authorization_code_flow(self):
         """
@@ -143,6 +152,7 @@ class TestKnoxIDF(unittest.TestCase):
         self.assertIn("id_token", tokens)
         self.assertIn("refresh_token", tokens)
         self.assertEqual(tokens["issued_token_type"], "urn:ietf:params:oauth:token-type:jwt")
+        self._assert_expires_in_relative_seconds(tokens)
 
         refresh_token = tokens["refresh_token"]
         print(f"Refresh token: {refresh_token}")
@@ -162,6 +172,7 @@ class TestKnoxIDF(unittest.TestCase):
         self.assertIn("access_token", new_tokens)
         self.assertIn("refresh_token", new_tokens)
         self.assertEqual(new_tokens["issued_token_type"], "urn:ietf:params:oauth:token-type:jwt")
+        self._assert_expires_in_relative_seconds(new_tokens)
 
         # Verify rotation: new refresh token should be different
         self.assertNotEqual(refresh_token, new_tokens["refresh_token"])
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 2cf6abeed..e397de441 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
@@ -212,6 +212,9 @@ public class TokenResource extends PasscodeTokenResourceBase {
     @Override
     protected ResponseMap buildResponseMap(JWT token, long expires) throws TokenServiceException {
         final ResponseMap responseMap = super.buildResponseMap(token, expires);
+        // RFC 8693 §2.2.1 / RFC 6749 §5.1: expires_in is a relative lifetime in seconds, not the
+        // inherited KNOXTOKEN absolute epoch-ms expiry.
+        addExpiryIfNotNever(responseMap.map);
 
         // RFC 8693 §2.2.1 requires the response to state the type of the issued token. Every KnoxIDF
         // grant (authorization_code, refresh_token and the client_credentials/other grants routed to
@@ -243,6 +246,16 @@ public class TokenResource extends PasscodeTokenResourceBase {
         return responseMap;
     }
 
+    @Override
+    protected void addExpiryIfNotNever(Map<String, Object> map) {
+        long expiresIn = getTokenLifetimeInSeconds();
+        if (expiresIn != -1) {
+            map.put(EXPIRES_IN, expiresIn);
+        } else {
+            map.remove(EXPIRES_IN);
+        }
+    }
+
     // Package-private for testability (the single-use rotation guard is exercised by
     // TokenResourceRefreshTokenRotationTest); not part of the public resource API.
     Response handleRefreshToken() {
diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceExpiresInTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceExpiresInTest.java
new file mode 100644
index 000000000..a7730a6d1
--- /dev/null
+++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceExpiresInTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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 jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.knox.gateway.services.security.token.TokenServiceException;
+import org.apache.knox.gateway.services.security.token.impl.JWT;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.easymock.EasyMock;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+public class TokenResourceExpiresInTest {
+
+    private static final String EXPIRES_IN = "expires_in";
+    private static final String ISSUED_TOKEN_TYPE = "issued_token_type";
+    private static final String ISSUED_TOKEN_TYPE_JWT_VALUE = "urn:ietf:params:oauth:token-type:jwt";
+    private static final long ONE_HOUR_SECONDS = 3600L;
+    private static final long ONE_HOUR_MS = 3_600_000L;
+    private static final String TOKEN_ID = "11111111-2222-3333-4444-555555555555";
+
+    static final class TestableTokenResource extends TokenResource {
+        private long lifetimeSeconds;
+
+        void configure(final long lifetimeSeconds, final HttpServletRequest request) throws Exception {
+            this.lifetimeSeconds = lifetimeSeconds;
+            this.request = request;
+            setParentContext(EasyMock.createNiceMock(ServletContext.class));
+        }
+
+        @Override
+        protected long getTokenLifetimeInSeconds() {
+            return lifetimeSeconds;
+        }
+
+        Map<String, Object> responseMap(final JWT token, final long expires) throws TokenServiceException {
+            return buildResponseMap(token, expires).map;
+        }
+
+        private void setParentContext(final ServletContext context) throws Exception {
+            final Field field = org.apache.knox.gateway.service.knoxtoken.TokenResource.class.getDeclaredField("context");
+            field.setAccessible(true);
+            field.set(this, context);
+        }
+    }
+
+    private static HttpServletRequest clientCredentialsRequest() {
+        final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
+        EasyMock.expect(request.getParameter("grant_type")).andReturn("client_credentials").anyTimes();
+        EasyMock.replay(request);
+        return request;
+    }
+
+    private static JWT jwt() {
+        final JWT token = EasyMock.createNiceMock(JWT.class);
+        EasyMock.expect(token.getClaim(JWTToken.KNOX_ID_CLAIM)).andReturn(TOKEN_ID).anyTimes();
+        EasyMock.replay(token);
+        return token;
+    }
+
+    @Test
+    public void testExpiresInIsRelativeSecondsNotAbsoluteEpochMs() throws Exception {
+        final TestableTokenResource resource = new TestableTokenResource();
+        resource.configure(ONE_HOUR_SECONDS, clientCredentialsRequest());
+        final long absoluteExpiry = System.currentTimeMillis() + ONE_HOUR_MS;
+
+        final Object expiresIn = resource.responseMap(jwt(), absoluteExpiry).get(EXPIRES_IN);
+
+        assertEquals("expires_in must be the relative lifetime in seconds, not epoch-ms.",
+                ONE_HOUR_SECONDS, expiresIn);
+        assertNotEquals("expires_in must not be the absolute epoch-ms expiry.", absoluteExpiry, expiresIn);
+        assertTrue("expires_in must be a small relative value, not an epoch-ms timestamp.",
+                ((Number) expiresIn).longValue() < absoluteExpiry / 1000);
+    }
+
+    @Test
+    public void testIssuedTokenTypeIsAdvertised() throws Exception {
+        final TestableTokenResource resource = new TestableTokenResource();
+        resource.configure(ONE_HOUR_SECONDS, clientCredentialsRequest());
+
+        final Object issuedTokenType =
+                resource.responseMap(jwt(), System.currentTimeMillis() + ONE_HOUR_MS).get(ISSUED_TOKEN_TYPE);
+
+        assertEquals("RFC 8693 §2.2.1 requires the issued_token_type of the minted JWT.",
+                ISSUED_TOKEN_TYPE_JWT_VALUE, issuedTokenType);
+    }
+
+    @Test
+    public void testExpiresInIsOmittedForUnlimitedLifetime() throws Exception {
+        final TestableTokenResource resource = new TestableTokenResource();
+        resource.configure(-1L, clientCredentialsRequest());
+
+        final Map<String, Object> map = resource.responseMap(jwt(), System.currentTimeMillis() + ONE_HOUR_MS);
+
+        assertFalse("expires_in is OPTIONAL (RFC 8693 §2.2.1) and must be omitted for an unlimited-lifetime token.",
+                map.containsKey(EXPIRES_IN));
+    }
+}
diff --git a/knox-site/docs/knoxidf/endpoints.md b/knox-site/docs/knoxidf/endpoints.md
index 269142fa7..819b5d852 100644
--- a/knox-site/docs/knoxidf/endpoints.md
+++ b/knox-site/docs/knoxidf/endpoints.md
@@ -190,7 +190,7 @@ access-token / refresh-token pair. Requires `client_secret`.
   "access_token": "<JWT>",
   "token_id": "<UUID>",
   "token_type": "Bearer",
-  "expires_in": 1699999999999,
+  "expires_in": 86400,
   "managed_token": "true",
   "id_token": "<JWT>",
   "refresh_token": "<JWT>",