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>",