Skip to content

Commit f67e7ad

Browse files
szhGitHub Enterprise
authored and
GitHub Enterprise
committed
Merge pull request #17 from Conjur-Enterprise/pr50
CNJR-0000: Add jwt authentication strategy
2 parents 53ad7bd + bc8ab4a commit f67e7ad

File tree

12 files changed

+229
-3
lines changed

12 files changed

+229
-3
lines changed

Diff for: CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
66

77
## [Unreleased]
88

9+
## [0.1.3] - 2025-02-24
10+
11+
### Added
12+
- Add support for authn-jwt
13+
[cyberark/conjur-api-python#50](https://github.com/cyberark/conjur-api-python/pull/50)
14+
915
## [0.1.2] - 2024-08-01
1016

1117
### Security

Diff for: README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,14 @@ We provide the `AuthnAuthenticationStrategy` for the default Conjur authenticato
114114
authn_provider = AuthnAuthenticationStrategy(credentials_provider)
115115
```
116116

117-
We also provide the `LdapAuthenticationStrategy` for the ldap authenticator, and `OidcAuthenticationStrategy` for the OIDC authenticator.
117+
We also provide the `LdapAuthenticationStrategy`, `OidcAuthenticationStrategy`, and `JWTAuthenticationStrategy` for the
118+
ldap, oidc, and jwt authenticators respectively.
118119
Example use:
119120

120121
```python
121122
authn_provider = LdapAuthenticationStrategy(credentials_provider)
122123
authn_provider = OidcAuthenticationStrategy(credentials_provider)
124+
jwt_provider = JWTAuthenticationStrategy(token)
123125
```
124126

125127
When using these strategies, make sure `connection_info` has a `service_id` specified.

Diff for: ci/test/Dockerfile.test

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM ubuntu:23.04
1+
FROM ubuntu:24.04
22
ENV INSTALL_DIR=/opt/conjur-api-python
33
ENV DEBIAN_FRONTEND=noninteractive
44

Diff for: ci/test/conjur-deployment/configuration/initial_policy.yml

+29
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
- !user test-valid-user
55
- !user test-invalid-user
66

7+
- !host
8+
9+
annotations:
10+
authn-jwt/test-service/sub: test-workload
11+
712
- !policy
813
id: conjur
914
body: []
@@ -58,6 +63,26 @@
5863
privilege: [ read, authenticate ]
5964
resource: !webservice
6065

66+
- !policy
67+
id: conjur/authn-jwt/test-service
68+
body:
69+
- !webservice
70+
71+
- !webservice
72+
id: status
73+
74+
- !variable jwks-uri
75+
- !variable token-app-property
76+
- !variable issuer
77+
- !variable audience
78+
79+
- !group users
80+
81+
- !permit
82+
role: !group users
83+
privilege: [ read, authenticate ]
84+
resource: !webservice
85+
6186
- !grant
6287
role: !group conjur/authn-ldap/test-service/users
6388
member: !user alice
@@ -66,6 +91,10 @@
6691
role: !group conjur/authn-oidc/test-service/users
6792
member: !user john.williams
6893

94+
- !grant
95+
role: !group conjur/authn-jwt/test-service/users
96+
member: !host [email protected]
97+
6998
- !permit
7099
role: !user alice
71100
privileges: [ read ]

Diff for: ci/test/conjur-deployment/ubuntu_compose.yml

+15-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ services:
3535
CONJUR_ACCOUNT: dev
3636
DATABASE_URL: postgres://postgres@pg/postgres
3737
RAILS_ENV: development
38-
CONJUR_AUTHENTICATORS: authn-ldap/test-service,authn-oidc/test-service,authn
38+
CONJUR_AUTHENTICATORS: authn-ldap/test-service,authn-oidc/test-service,authn-jwt/test-service,authn
3939
LDAP_URI: ldap://ldap-server:389
4040
LDAP_BASE: dc=conjur,dc=net
4141
LDAP_BINDDN: cn=admin,dc=conjur,dc=net
@@ -50,6 +50,7 @@ services:
5050
- pg
5151
- ldap-server
5252
- oidc-server
53+
- jwt-server
5354

5455
conjur-https:
5556
image: nginx:alpine
@@ -108,6 +109,19 @@ services:
108109
- "9443:443"
109110
volumes:
110111
- ${PWD}/ci/test/conjur-deployment/configuration/oidc:/oidc:ro
112+
113+
jwt-server:
114+
build:
115+
context: ./mock-jwt-server
116+
dockerfile: Dockerfile
117+
ports:
118+
- 8008:8080
119+
environment:
120+
ISSUER: "jwt-server"
121+
AUDIENCE: "conjur"
122+
SUBJECT: "test-workload"
123+
124+
EXTERNAL_PORT: "8008"
111125

112126
test:
113127
privileged: true

Diff for: ci/test/test_integration

+6
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ cleanup
3636

3737
export REGISTRY_URL=${INFRAPOOL_REGISTRY_URL:-"docker.io"}
3838

39+
# Ensure we are in repo root
40+
cd "$(git rev-parse --show-toplevel)"
41+
# Clone mock-jwt-server dependency
42+
rm -rf mock-jwt-server
43+
git clone https://github.com/gl-johnson/mock-jwt-server.git || true
44+
3945
echo "Building API container..."
4046
docker_compose_command build test
4147

Diff for: conjur_api/http/endpoints.py

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class ConjurEndpoint(Enum):
1919
AUTHENTICATE = "{url}/authn/{account}/{login}/authenticate"
2020
AUTHENTICATE_LDAP = "{url}/authn-ldap/{service_id}/{account}/{login}/authenticate"
2121
AUTHENTICATE_OIDC = "{url}/authn-oidc/{service_id}/{account}/authenticate"
22+
AUTHENTICATE_JWT = "{url}/authn-jwt/{service_id}/{account}/authenticate"
2223
# deepcode ignore NoHardcodedCredentials: False positive - this is a URL, not a credential
2324
LOGIN = "{url}/authn/{account}/login"
2425
LOGIN_LDAP = "{url}/authn-ldap/{service_id}/{account}/login"

Diff for: conjur_api/providers/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
from conjur_api.providers.authn_authentication_strategy import AuthnAuthenticationStrategy
88
from conjur_api.providers.ldap_authentication_strategy import LdapAuthenticationStrategy
99
from conjur_api.providers.oidc_authentication_strategy import OidcAuthenticationStrategy
10+
from conjur_api.providers.jwt_authentication_strategy import JWTAuthenticationStrategy

Diff for: conjur_api/providers/jwt_authentication_strategy.py

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
JWTAuthenticationStrategy module
4+
5+
This module holds the JWTAuthenticationStrategy class
6+
"""
7+
8+
import base64
9+
import json
10+
import logging
11+
from datetime import datetime, timedelta
12+
from typing import Tuple
13+
14+
from conjur_api.errors.errors import MissingRequiredParameterException
15+
from conjur_api.http.endpoints import ConjurEndpoint
16+
from conjur_api.interface import AuthenticationStrategyInterface
17+
from conjur_api.models.general.conjur_connection_info import \
18+
ConjurConnectionInfo
19+
from conjur_api.models.ssl.ssl_verification_metadata import \
20+
SslVerificationMetadata
21+
from conjur_api.wrappers.http_wrapper import HttpVerb, invoke_endpoint
22+
23+
# Tokens should only be reused for 5 minutes (max lifetime is 8 minutes)
24+
DEFAULT_TOKEN_EXPIRATION = 8
25+
API_TOKEN_SAFETY_BUFFER = 3
26+
DEFAULT_API_TOKEN_DURATION = DEFAULT_TOKEN_EXPIRATION - API_TOKEN_SAFETY_BUFFER
27+
28+
class JWTAuthenticationStrategy(AuthenticationStrategyInterface):
29+
"""
30+
JWTAuthenticationStrategy
31+
32+
This class makes an HTTP POST request to authenticate and retrieve a token.
33+
"""
34+
35+
def __init__(self, jwt_token: str):
36+
"""
37+
Initializes the JWTAuthenticationStrategy with a JWT token.
38+
39+
:param jwt_token: The JWT token to authenticate with
40+
"""
41+
self.jwt_token = jwt_token # Store JWT token in the class
42+
43+
async def authenticate(
44+
self,
45+
connection_info: ConjurConnectionInfo,
46+
ssl_verification_data: SslVerificationMetadata,
47+
) -> Tuple[str, datetime]:
48+
"""
49+
Authenticate method makes a POST request to the authentication endpoint,
50+
retrieves a token, and calculates the token expiration.
51+
"""
52+
logging.debug("Authenticating to %s...", connection_info.conjur_url)
53+
54+
api_token = await self._send_authenticate_request(ssl_verification_data, connection_info)
55+
56+
return api_token, self._calculate_token_expiration(api_token)
57+
58+
async def _send_authenticate_request(self, ssl_verification_data, connection_info):
59+
self._validate_service_id_exists(connection_info)
60+
61+
params = {
62+
'url': connection_info.conjur_url,
63+
'service_id': connection_info.service_id,
64+
'account': connection_info.conjur_account,
65+
}
66+
data = f"jwt={self.jwt_token}"
67+
68+
response = await invoke_endpoint(
69+
HttpVerb.POST,
70+
ConjurEndpoint.AUTHENTICATE_JWT,
71+
params,
72+
data,
73+
ssl_verification_metadata=ssl_verification_data,
74+
proxy_params=connection_info.proxy_params)
75+
return response.text
76+
77+
def _validate_service_id_exists(self, connection_info: ConjurConnectionInfo):
78+
if not connection_info.service_id:
79+
raise MissingRequiredParameterException("service_id is required for authn-jwt")
80+
81+
@staticmethod
82+
# pylint: disable=bare-except
83+
def _calculate_token_expiration(api_token: str) -> datetime:
84+
"""
85+
Calculate the expiration of the token by decoding the payload and extracting 'exp'.
86+
"""
87+
try:
88+
# The token is in JSON format. Each field in the token is base64 encoded.
89+
# Decode the payload field and extract the expiration date.
90+
decoded_token_payload = base64.b64decode(json.loads(api_token)['payload'].encode('ascii'))
91+
token_expiration = json.loads(decoded_token_payload)['exp']
92+
return datetime.fromtimestamp(token_expiration) - timedelta(minutes=API_TOKEN_SAFETY_BUFFER)
93+
except:
94+
# If we can't extract the expiration from the token, fall back to the default expiration
95+
return datetime.now() + timedelta(minutes=DEFAULT_API_TOKEN_DURATION)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from aiounittest import AsyncTestCase
2+
3+
from conjur_api.errors.errors import MissingRequiredParameterException
4+
from conjur_api.models.general.conjur_connection_info import ConjurConnectionInfo
5+
from conjur_api.models.general.credentials_data import CredentialsData
6+
from conjur_api.providers import JWTAuthenticationStrategy
7+
8+
class JWTAuthenticationStrategyTest(AsyncTestCase):
9+
10+
async def test_missing_serviceid(self):
11+
conjur_url = "https://conjur.example.com"
12+
connection_info = ConjurConnectionInfo(conjur_url, "some_account")
13+
14+
jwt_token = "eyJhb..."
15+
16+
provider = JWTAuthenticationStrategy(jwt_token)
17+
with self.assertRaises(MissingRequiredParameterException) as context:
18+
await provider.authenticate(connection_info, None)
19+
20+
self.assertRegex(context.exception.message, "service_id is required")

Diff for: tests/integration/integ_utils.py

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from conjur_api.models.general.conjur_connection_info import ConjurConnectionInfo
77
from conjur_api.providers import SimpleCredentialsProvider, AuthnAuthenticationStrategy, LdapAuthenticationStrategy
88
from conjur_api.providers.oidc_authentication_strategy import OidcAuthenticationStrategy
9+
from conjur_api.providers.jwt_authentication_strategy import JWTAuthenticationStrategy
910

1011

1112
class ConjurUser:
@@ -18,6 +19,7 @@ class AuthenticationStrategyType(Enum):
1819
AUTHN = 'AUTHN'
1920
LDAP = 'LDAP'
2021
OIDC = 'OIDC'
22+
JWT = 'JWT'
2123

2224

2325
async def create_client(username: str, password: str,
@@ -37,6 +39,8 @@ async def create_client(username: str, password: str,
3739
authn_strategy: AuthenticationStrategyInterface
3840
if authn_strategy_type == AuthenticationStrategyType.OIDC:
3941
authn_strategy = OidcAuthenticationStrategy(credentials_provider)
42+
elif authn_strategy_type == AuthenticationStrategyType.JWT:
43+
authn_strategy = JWTAuthenticationStrategy(password) # password is the JWT token
4044
elif authn_strategy_type == AuthenticationStrategyType.LDAP:
4145
authn_strategy = LdapAuthenticationStrategy(credentials_provider)
4246
else:
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import asyncio
2+
import os
3+
4+
import requests
5+
from aiounittest import AsyncTestCase
6+
from requests.auth import HTTPBasicAuth
7+
8+
from conjur_api.errors.errors import HttpStatusError
9+
from tests.integration.integ_utils import (AuthenticationStrategyType,
10+
ConjurUser, create_client)
11+
12+
13+
class TestJWTAuthentication(AsyncTestCase):
14+
15+
@classmethod
16+
def setUpClass(cls):
17+
asyncio.run(cls._add_test_data())
18+
19+
async def test_jwt_authentication_success(self):
20+
c = await create_client("", self.valid_jwt, AuthenticationStrategyType.JWT,
21+
service_id='test-service')
22+
23+
response = await c.whoami()
24+
self.assertTrue(response['username'] == 'host/[email protected]')
25+
26+
async def test_jwt_authentication_failure_invalid_token(self):
27+
c = await create_client("", self.invalid_jwt, AuthenticationStrategyType.JWT,
28+
service_id='test-service')
29+
30+
with self.assertRaises(HttpStatusError) as context:
31+
response = await c.whoami()
32+
self.assertEqual(context.exception.status, 401)
33+
34+
@classmethod
35+
async def _add_test_data(cls):
36+
c = await create_client("admin", os.environ['CONJUR_AUTHN_API_KEY'])
37+
await c.set('conjur/authn-jwt/test-service/jwks-uri', 'http://jwt-server:8080/.well-known/jwks.json')
38+
await c.set('conjur/authn-jwt/test-service/token-app-property', 'email')
39+
await c.set('conjur/authn-jwt/test-service/audience', 'conjur')
40+
await c.set('conjur/authn-jwt/test-service/issuer', 'jwt-server')
41+
42+
url = 'http://jwt-server:8080/token'
43+
44+
# file deepcode ignore SSLVerificationBypass/test: This is a test file and we are using a local server
45+
x = requests.get(url, verify=False)
46+
47+
cls.valid_jwt = x.json()['token']
48+
cls.invalid_jwt = 'invalid_token'

0 commit comments

Comments
 (0)