-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(conduit): Add conduit auth functions #101729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
IanWoodard
wants to merge
4
commits into
master
Choose a base branch
from
ianwoodard/infreng-97-create-helper-functions-for-generating-tokens-and-channel
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import time | ||
import uuid | ||
from typing import NamedTuple | ||
|
||
from django.conf import settings | ||
|
||
from sentry.utils import jwt, metrics | ||
|
||
TOKEN_TTL_SEC = 600 # 10 minutes | ||
|
||
|
||
class ConduitCredentials(NamedTuple): | ||
token: str | ||
channel_id: str | ||
url: str | ||
|
||
|
||
def generate_channel_id() -> str: | ||
""" | ||
Generate a unique channel ID for a Conduit stream. | ||
|
||
Returns: | ||
UUID string | ||
""" | ||
return str(uuid.uuid4()) | ||
|
||
|
||
def generate_conduit_token( | ||
org_id: int, | ||
channel_id: str, | ||
issuer: str | None = None, | ||
audience: str | None = None, | ||
conduit_private_key: str | None = None, | ||
) -> str: | ||
""" | ||
Generate a JWT token for Conduit authentication. | ||
|
||
Args: | ||
org_id: Sentry organization ID | ||
channel_id: The channel UUID for the conduit stream | ||
issuer: JWT claim for the issuer of the token | ||
audience: JWT claim for the audience of the token | ||
conduit_private_key: RSA private key | ||
|
||
Returns: | ||
JWT token string | ||
""" | ||
if issuer is None: | ||
issuer = settings.CONDUIT_JWT_ISSUER | ||
if audience is None: | ||
audience = settings.CONDUIT_JWT_AUDIENCE | ||
if conduit_private_key is None: | ||
conduit_private_key = settings.CONDUIT_PRIVATE_KEY | ||
if conduit_private_key is None: | ||
raise ValueError("CONDUIT_PRIVATE_KEY not configured") | ||
|
||
now = int(time.time()) | ||
exp = now + TOKEN_TTL_SEC | ||
payload = { | ||
"org_id": org_id, | ||
"channel_id": channel_id, | ||
"iat": now, | ||
# Conduit only validates tokens on initial connection, not for stream lifetime | ||
"exp": exp, | ||
"iss": issuer, | ||
"aud": audience, | ||
} | ||
return jwt.encode(payload, conduit_private_key, algorithm="RS256") | ||
|
||
|
||
def get_conduit_credentials( | ||
org_id: int, | ||
gateway_url: str | None = None, | ||
) -> ConduitCredentials: | ||
""" | ||
Generate all credentials needed to connect to Conduit. | ||
|
||
Returns: | ||
ConduitCredentials with token, channel_id, algorithm, and url | ||
""" | ||
if gateway_url is None: | ||
gateway_url = settings.CONDUIT_GATEWAY_URL | ||
channel_id = generate_channel_id() | ||
token = generate_conduit_token(org_id, channel_id) | ||
|
||
metrics.incr( | ||
"conduit.credentials.generated", | ||
tags={"org_id": org_id}, | ||
sample_rate=1.0, | ||
) | ||
|
||
return ConduitCredentials( | ||
token=token, | ||
channel_id=channel_id, | ||
url=f"{gateway_url}/events/{org_id}", | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
import time | ||
from unittest.mock import patch | ||
|
||
import jwt as pyjwt | ||
import pytest | ||
|
||
from sentry.conduit.auth import generate_channel_id, generate_conduit_token, get_conduit_credentials | ||
from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY | ||
|
||
|
||
def test_generate_channel_id_is_valid_uuid(): | ||
"""Should generate a valid uuid.""" | ||
channel_id = generate_channel_id() | ||
|
||
# UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | ||
assert isinstance(channel_id, str) | ||
assert len(channel_id) == 36 # Length of UUID | ||
assert channel_id.count("-") == 4 | ||
|
||
|
||
def test_generate_channel_id_is_unique(): | ||
"""Should generate unique channel_ids.""" | ||
assert generate_channel_id() != generate_channel_id() | ||
|
||
|
||
def test_generate_conduit_token_is_valid_jwt(): | ||
"""Should generate a valid JWT token with RS256.""" | ||
org_id = 123 | ||
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce" | ||
|
||
token = generate_conduit_token( | ||
org_id, | ||
channel_id, | ||
issuer="sentry", | ||
audience="conduit", | ||
conduit_private_key=RS256_KEY, | ||
) | ||
|
||
assert isinstance(token, str) | ||
assert token.count(".") == 2 | ||
|
||
claims = pyjwt.decode(token, RS256_PUB_KEY, algorithms=["RS256"], audience="conduit") | ||
|
||
assert claims["channel_id"] == channel_id | ||
assert claims["org_id"] == org_id | ||
assert claims["iss"] == "sentry" | ||
assert claims["aud"] == "conduit" | ||
assert "iat" in claims | ||
assert "exp" in claims | ||
|
||
|
||
def test_generate_conduit_token_has_expiration(): | ||
"""Token should expire in 10 minutes.""" | ||
org_id = 123 | ||
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce" | ||
|
||
before_time = int(time.time()) | ||
token = generate_conduit_token( | ||
org_id, | ||
channel_id, | ||
issuer="sentry", | ||
audience="conduit", | ||
conduit_private_key=RS256_KEY, | ||
) | ||
after_time = int(time.time()) | ||
|
||
claims = pyjwt.decode( | ||
token, | ||
RS256_PUB_KEY, | ||
algorithms=["RS256"], | ||
audience="conduit", | ||
options={"verify_exp": False}, | ||
) | ||
|
||
exp_time = claims["exp"] | ||
iat_time = claims["iat"] | ||
|
||
assert iat_time >= before_time | ||
assert iat_time <= after_time | ||
assert exp_time == iat_time + 600 | ||
|
||
|
||
def test_generate_conduit_token_uses_settings(): | ||
"""Should use settings when parameters are not provided.""" | ||
org_id = 123 | ||
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce" | ||
|
||
with patch("sentry.conduit.auth.settings") as mock_settings: | ||
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY | ||
mock_settings.CONDUIT_JWT_ISSUER = "test-issuer" | ||
mock_settings.CONDUIT_JWT_AUDIENCE = "test-audience" | ||
|
||
token = generate_conduit_token( | ||
org_id, | ||
channel_id, | ||
) | ||
|
||
claims = pyjwt.decode( | ||
token, | ||
RS256_PUB_KEY, | ||
algorithms=["RS256"], | ||
audience="test-audience", | ||
options={"verify_exp": False}, | ||
) | ||
|
||
assert claims["iss"] == "test-issuer" | ||
assert claims["aud"] == "test-audience" | ||
|
||
|
||
def test_generate_conduit_token_raises_when_missing(): | ||
"""Should raise an error if the private key is not configured.""" | ||
org_id = 123 | ||
channel_id = "ad342057-d66b-4ed4-ab01-3415dd2cb1ce" | ||
with pytest.raises(ValueError, match="CONDUIT_PRIVATE_KEY not configured"): | ||
generate_conduit_token( | ||
org_id, | ||
channel_id, | ||
) | ||
|
||
|
||
def test_get_conduit_credentials_returns_all_credentials(): | ||
"""Should return a url, token, and channel_id.""" | ||
gateway_url = "https://conduit.example.com" | ||
with patch("sentry.conduit.auth.settings") as mock_settings: | ||
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY | ||
mock_settings.CONDUIT_JWT_ISSUER = "sentry" | ||
mock_settings.CONDUIT_JWT_AUDIENCE = "conduit" | ||
mock_settings.CONDUIT_GATEWAY_URL = gateway_url | ||
|
||
org_id = 123 | ||
result = get_conduit_credentials(org_id) | ||
|
||
assert isinstance(result.token, str) | ||
assert isinstance(result.channel_id, str) | ||
assert isinstance(result.url, str) | ||
|
||
assert str(org_id) in result.url | ||
assert result.url == f"{gateway_url}/events/{org_id}" | ||
|
||
|
||
def test_get_conduit_credentials_uses_custom_url(): | ||
"""Should use provided gateway_url instead of settings.""" | ||
gateway_url = "https://custom.conduit.io" | ||
with patch("sentry.conduit.auth.settings") as mock_settings: | ||
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY | ||
mock_settings.CONDUIT_JWT_ISSUER = "sentry" | ||
mock_settings.CONDUIT_JWT_AUDIENCE = "conduit" | ||
|
||
org_id = 123 | ||
result = get_conduit_credentials(org_id, gateway_url) | ||
|
||
assert isinstance(result.token, str) | ||
assert isinstance(result.channel_id, str) | ||
assert isinstance(result.url, str) | ||
|
||
assert str(org_id) in result.url | ||
assert result.url == f"{gateway_url}/events/{org_id}" | ||
|
||
|
||
def test_get_conduit_credentials_token_is_valid(): | ||
"""Generated token should be decodable with correct claims.""" | ||
gateway_url = "https://conduit.example.com" | ||
with patch("sentry.conduit.auth.settings") as mock_settings: | ||
mock_settings.CONDUIT_PRIVATE_KEY = RS256_KEY | ||
mock_settings.CONDUIT_JWT_ISSUER = "sentry" | ||
mock_settings.CONDUIT_JWT_AUDIENCE = "conduit" | ||
mock_settings.CONDUIT_GATEWAY_URL = gateway_url | ||
|
||
org_id = 123 | ||
result = get_conduit_credentials(org_id) | ||
|
||
claims = pyjwt.decode( | ||
result.token, | ||
RS256_PUB_KEY, | ||
algorithms=["RS256"], | ||
audience="conduit", | ||
options={"verify_exp": False}, | ||
) | ||
|
||
assert claims["org_id"] == org_id | ||
assert claims["channel_id"] == result.channel_id | ||
|
||
assert str(org_id) in result.url | ||
assert result.url == f"{gateway_url}/events/{org_id}" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: JWT Time Source Inconsistency
The JWT
iat
(issued at) andexp
(expiration) claims are calculated using different time sources and at separate points in the function. This inconsistency means the token's expiration may not be exactly 10 minutes from its issued time, which can lead to flaky tests and tokens with slightly incorrect validity.