vincbeck commented on code in PR #75913:
URL: https://github.com/apache/airflow/pull/75913#discussion_r4415321814


##########
providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py:
##########
@@ -194,19 +232,81 @@ def create_client_credentials_token(
             detail="Client credentials authentication failed",
         )
 
-    # For client_credentials, get the service account user info
     # The token represents the service account associated with the client
-    userinfo_raw: dict | bytes = client.userinfo(tokens["access_token"])
-    # Decode bytes to dict if necessary
-    userinfo: dict = json.loads(userinfo_raw) if isinstance(userinfo_raw, 
bytes) else userinfo_raw
+    return _create_api_jwt_from_keycloak_tokens(client, tokens, 
expiration_time_in_seconds)
 
-    user = KeycloakAuthManagerUser(
-        user_id=userinfo["sub"],
-        name=userinfo.get("preferred_username", userinfo.get("clientId", 
"service-account")),
-        access_token=tokens["access_token"],
-        refresh_token=tokens.get(
-            "refresh_token"
-        ),  # client_credentials may not return refresh_token (RFC6749 section 
4.4.3)
-    )
 
-    return get_auth_manager().generate_api_jwt(user, 
expiration_time_in_seconds=expiration_time_in_seconds)
+def create_token_from_authorization_code(
+    code: str,
+    code_verifier: str,
+    redirect_uri: str,
+    expiration_time_in_seconds: int = conf.getint("api_auth", 
"jwt_cli_expiration_time"),
+) -> str:
+    """
+    Create a CLI token from an authorization code obtained through 
``/login/cli``.
+
+    Keycloak checks ``code_verifier`` against the challenge ``/login/cli`` 
forwarded, so
+    the code is useless to anyone who intercepted it on the loopback redirect.
+    """
+    if not is_cli_authorization_code_enabled():
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="The authorization_code grant is not enabled for 
command-line clients",
+        )
+    if not is_cli_loopback_redirect_uri(redirect_uri):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 
detail="Invalid redirect_uri")
+
+    client = KeycloakAuthManager.get_keycloak_client()
+    try:
+        tokens = client.token(
+            grant_type="authorization_code",
+            code=code,
+            redirect_uri=redirect_uri,
+            code_verifier=code_verifier,
+            scope="openid",
+        )
+    except KeycloakError:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 
detail="Invalid authorization code")
+
+    return _create_api_jwt_from_keycloak_tokens(client, tokens, 
expiration_time_in_seconds)
+
+
+def refresh_cli_token(token: str) -> str:
+    """
+    Create a CLI token with fresh Keycloak tokens from a CLI token that is 
still valid.
+
+    The new token expires with the old one, so refreshing never extends a CLI 
session.
+    Expired and revoked tokens are refused by ``get_user_from_token``.
+    """
+    auth_manager = get_auth_manager()
+    try:
+        user = cast("KeycloakAuthManagerUser | None", 
async_to_sync(auth_manager.get_user_from_token)(token))
+    except jwt.PyJWTError:
+        user = None
+    if not user:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 
detail="Invalid token")
+    if not user.refresh_token:
+        # Separate from the rejections above so that a deployment whose 
Keycloak client issues no
+        # refresh token gets an answer it can act on instead of a token that 
merely looks invalid.
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="This token cannot be refreshed because Keycloak issued it 
without a refresh token",
+        )
+
+    # The signature and expiry were verified by get_user_from_token above, but 
it allows
+    # [api_auth] jwt_leeway, so a token that just expired would refresh into a 
negative lifetime.
+    expires_at = jwt.decode(token, options={"verify_signature": False})["exp"]
+    expiration_time_in_seconds = int(expires_at - time.time())
+    if expiration_time_in_seconds <= 0:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 
detail="Invalid token")
+
+    client = KeycloakAuthManager.get_keycloak_client()
+    try:
+        tokens = client.refresh_token(user.refresh_token)
+    except KeycloakError:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 
detail="Invalid token")
+
+    user.access_token = tokens["access_token"]
+    # RFC 6749 section 6: a refresh response may omit a new refresh token, and 
the old one stays valid.
+    user.refresh_token = tokens.get("refresh_token", user.refresh_token)
+    return auth_manager.generate_api_jwt(user, 
expiration_time_in_seconds=expiration_time_in_seconds)

Review Comment:
   Logout doesn't fully end a CLI session. `refresh_cli_token` creates a new 
token but never revokes the one it was given. Example: a user logs in (token 
A), refreshes (token B), then logs out, which revokes only B. A still works and 
can be refreshed until it expires. Fix: call `auth_manager.revoke_token(token)` 
after a successful refresh.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to