Skip to content

Commit 0f3d62a

Browse files
ajay-kAjay Kumar
andauthored
Fixes organization_id param in get_authorization_url endpoint (#98)
* Fixes organization_id param in get_authorization_url endpoint * Updates Base URL + Requirements Version * Update code for latest workos version --------- Co-authored-by: Ajay Kumar <ajay_k@Ajays-MacBook.local>
1 parent 81d3633 commit 0f3d62a

4 files changed

Lines changed: 296 additions & 29 deletions

File tree

python-django-sso-example/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ pytz==2021.1
77
requests==2.25.1
88
sqlparse==0.4.2
99
urllib3==1.26.5
10-
workos>=1.23.3
10+
workos>=5.37.0
1111
python-dotenv
Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,186 @@
1-
from django.test import TestCase
1+
from django.test import TestCase, Client
2+
from django.urls import reverse
3+
from unittest.mock import patch, MagicMock
4+
import os
5+
# Import views module to ensure workos is loaded before patching
6+
from sso import views
27

3-
# Create your tests here.
8+
9+
class SSOViewTests(TestCase):
10+
def setUp(self):
11+
self.client = Client()
12+
# Set environment variables for testing
13+
os.environ["WORKOS_API_KEY"] = "test_api_key"
14+
os.environ["WORKOS_CLIENT_ID"] = "test_client_id"
15+
os.environ["REDIRECT_URI"] = "http://localhost:8000/auth/callback"
16+
17+
def tearDown(self):
18+
# Clean up environment variables
19+
if "WORKOS_API_KEY" in os.environ:
20+
del os.environ["WORKOS_API_KEY"]
21+
if "WORKOS_CLIENT_ID" in os.environ:
22+
del os.environ["WORKOS_CLIENT_ID"]
23+
if "REDIRECT_URI" in os.environ:
24+
del os.environ["REDIRECT_URI"]
25+
26+
def test_login_no_session(self):
27+
"""Test login view when no session is active"""
28+
response = self.client.get(reverse("login"))
29+
self.assertEqual(response.status_code, 200)
30+
self.assertTemplateUsed(response, "sso/login.html")
31+
32+
def test_login_with_active_session(self):
33+
"""Test login view when session is active"""
34+
session = self.client.session
35+
session["session_active"] = True
36+
session["p_profile"] = {"profile": {"first_name": "Test"}}
37+
session["first_name"] = "Test"
38+
session["raw_profile"] = {"email": "test@example.com"}
39+
session.save()
40+
41+
response = self.client.get(reverse("login"))
42+
self.assertEqual(response.status_code, 200)
43+
self.assertTemplateUsed(response, "sso/login_successful.html")
44+
self.assertIn("p_profile", response.context)
45+
self.assertIn("first_name", response.context)
46+
self.assertIn("raw_profile", response.context)
47+
48+
def test_auth_saml_login(self):
49+
"""Test auth view for SAML login"""
50+
# Create a mock sso object
51+
mock_sso = MagicMock()
52+
mock_sso.get_authorization_url.return_value = "https://api.workos.com/sso/authorize?test=123"
53+
54+
# Create a mock client with sso attribute
55+
mock_client = MagicMock()
56+
mock_client.sso = mock_sso
57+
58+
with patch.object(views, "workos_client", mock_client):
59+
response = self.client.post(
60+
reverse("auth"),
61+
{"login_method": "saml"},
62+
follow=False
63+
)
64+
65+
# Verify get_authorization_url was called with correct params
66+
mock_sso.get_authorization_url.assert_called_once()
67+
call_args = mock_sso.get_authorization_url.call_args
68+
self.assertIn("redirect_uri", call_args.kwargs)
69+
self.assertIn("state", call_args.kwargs)
70+
self.assertIn("organization_id", call_args.kwargs)
71+
self.assertEqual(call_args.kwargs["organization_id"], views.CUSTOMER_ORGANIZATION_ID)
72+
self.assertNotIn("provider", call_args.kwargs)
73+
74+
# Verify redirect response
75+
self.assertEqual(response.status_code, 302)
76+
self.assertEqual(response.url, "https://api.workos.com/sso/authorize?test=123")
77+
78+
def test_auth_provider_login(self):
79+
"""Test auth view for provider-based login (Google, Microsoft, etc.)"""
80+
# Create a mock sso object
81+
mock_sso = MagicMock()
82+
mock_sso.get_authorization_url.return_value = "https://api.workos.com/sso/authorize?provider=google"
83+
84+
# Create a mock client with sso attribute
85+
mock_client = MagicMock()
86+
mock_client.sso = mock_sso
87+
88+
with patch.object(views, "workos_client", mock_client):
89+
response = self.client.post(
90+
reverse("auth"),
91+
{"login_method": "google"},
92+
follow=False
93+
)
94+
95+
# Verify get_authorization_url was called with correct params
96+
mock_sso.get_authorization_url.assert_called_once()
97+
call_args = mock_sso.get_authorization_url.call_args
98+
self.assertIn("redirect_uri", call_args.kwargs)
99+
self.assertIn("state", call_args.kwargs)
100+
self.assertIn("provider", call_args.kwargs)
101+
self.assertEqual(call_args.kwargs["provider"], "google")
102+
self.assertNotIn("organization_id", call_args.kwargs)
103+
104+
# Verify redirect response
105+
self.assertEqual(response.status_code, 302)
106+
self.assertEqual(response.url, "https://api.workos.com/sso/authorize?provider=google")
107+
108+
def test_auth_callback_success(self):
109+
"""Test auth_callback view with valid code"""
110+
# Mock the profile response - in SDK v5+, ProfileAndToken uses .dict() method
111+
mock_profile = MagicMock()
112+
mock_profile.dict.return_value = {
113+
"profile": {
114+
"first_name": "John",
115+
"last_name": "Doe",
116+
"email": "john.doe@example.com"
117+
},
118+
"access_token": "test_token"
119+
}
120+
121+
# Create a mock sso object
122+
mock_sso = MagicMock()
123+
mock_sso.get_profile_and_token.return_value = mock_profile
124+
125+
# Create a mock client with sso attribute
126+
mock_client = MagicMock()
127+
mock_client.sso = mock_sso
128+
129+
with patch.object(views, "workos_client", mock_client):
130+
response = self.client.get(
131+
reverse("auth_callback"),
132+
{"code": "test_auth_code"},
133+
follow=True
134+
)
135+
136+
# Verify get_profile_and_token was called with the code
137+
mock_sso.get_profile_and_token.assert_called_once_with("test_auth_code")
138+
139+
# Verify session data was set
140+
self.assertTrue(self.client.session.get("session_active"))
141+
self.assertIn("p_profile", self.client.session)
142+
self.assertEqual(self.client.session["first_name"], "John")
143+
self.assertIn("raw_profile", self.client.session)
144+
145+
# Verify redirect to login
146+
self.assertEqual(response.status_code, 200)
147+
self.assertTemplateUsed(response, "sso/login_successful.html")
148+
149+
def test_auth_callback_missing_code(self):
150+
"""Test auth_callback view when code parameter is missing"""
151+
# Create a mock sso object
152+
mock_sso = MagicMock()
153+
154+
# Create a mock client with sso attribute
155+
mock_client = MagicMock()
156+
mock_client.sso = mock_sso
157+
158+
# This should render login page with error message (not raise KeyError)
159+
with patch.object(views, "workos_client", mock_client):
160+
response = self.client.get(reverse("auth_callback"))
161+
self.assertEqual(response.status_code, 200)
162+
self.assertTemplateUsed(response, "sso/login.html")
163+
self.assertIn("error", response.context)
164+
self.assertEqual(response.context["error"], "missing_code")
165+
166+
def test_logout(self):
167+
"""Test logout view clears session and redirects"""
168+
# Set up a session first
169+
session = self.client.session
170+
session["session_active"] = True
171+
session["p_profile"] = {"profile": {"first_name": "Test"}}
172+
session.save()
173+
174+
# Verify session has data
175+
self.assertTrue(self.client.session.get("session_active"))
176+
177+
# Call logout
178+
response = self.client.get(reverse("logout"), follow=True)
179+
180+
# Verify session is cleared
181+
self.assertFalse(self.client.session.get("session_active"))
182+
self.assertNotIn("p_profile", self.client.session)
183+
184+
# Verify redirect to login
185+
self.assertEqual(response.status_code, 200)
186+
self.assertTemplateUsed(response, "sso/login.html")

python-django-sso-example/sso/views.py

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,62 @@
11
import os
2-
import workos
2+
from workos import WorkOSClient
33
import json
44
from django.conf import settings
55
from django.shortcuts import redirect, render
66
from django.urls import reverse
7+
from pathlib import Path
8+
from dotenv import load_dotenv
9+
10+
# Load environment variables from .env file if it exists
11+
# BASE_DIR is the project root (where manage.py is located)
12+
# views.py is at: python-django-sso-example/sso/views.py
13+
# So we need to go up 2 levels to get to python-django-sso-example/
14+
BASE_DIR = Path(__file__).resolve().parent.parent
15+
env_path = BASE_DIR / ".env"
16+
load_dotenv(env_path, override=False) # Don't override existing env vars
17+
18+
19+
# Initialize WorkOS client
20+
# Note: In SDK v5+, we use WorkOSClient instance instead of workos.client module
21+
def get_workos_client():
22+
"""Get WorkOS client instance (initialized lazily)"""
23+
if not hasattr(get_workos_client, '_instance'):
24+
# Reload .env file in case it wasn't loaded at import time
25+
load_dotenv(env_path, override=False)
26+
27+
api_key = os.getenv("WORKOS_API_KEY")
28+
client_id = os.getenv("WORKOS_CLIENT_ID")
29+
if not api_key or not client_id:
30+
raise ValueError(
31+
"WorkOS API key and client ID must be set via WORKOS_API_KEY and WORKOS_CLIENT_ID environment variables. "
32+
"Please check your .env file or export these variables."
33+
)
34+
get_workos_client._instance = WorkOSClient(
35+
api_key=api_key,
36+
client_id=client_id
37+
)
38+
return get_workos_client._instance
39+
40+
# For compatibility with other examples, create workos_client variable
41+
# Initialize it if env vars are available, otherwise it will be created on first use
42+
try:
43+
if os.getenv("WORKOS_API_KEY") and os.getenv("WORKOS_CLIENT_ID"):
44+
workos_client = WorkOSClient(
45+
api_key=os.getenv("WORKOS_API_KEY"),
46+
client_id=os.getenv("WORKOS_CLIENT_ID")
47+
)
48+
else:
49+
workos_client = None
50+
except ValueError:
51+
# If env vars aren't set at import time, use lazy initialization
52+
workos_client = None
753

8-
9-
workos.api_key = os.getenv("WORKOS_API_KEY")
10-
workos.client_id = os.getenv("WORKOS_CLIENT_ID")
11-
12-
# In workos_django/settings.py, you can use DEBUG=True for local development,
13-
# but you must use DEBUG=False in order to test the full authentication flow
14-
# with the WorkOS API.
15-
workos.base_api_url = (
16-
"http://localhost:8000/" if settings.DEBUG else workos.base_api_url
17-
)
54+
# Set custom API base URL for local development
55+
if settings.DEBUG:
56+
os.environ["WORKOS_API_BASE_URL"] = "http://localhost:8000/"
1857

1958
# Constants
20-
# Required: Fill in CUSTOMER_ORGANIZATION_ID for the desired organization from the WorkOS Dashboard
21-
22-
CUSTOMER_ORGANIZATION_ID = "xxx"
59+
CUSTOMER_ORGANIZATION_ID = os.getenv("CUSTOMER_ORGANIZATION_ID")
2360
REDIRECT_URI = os.getenv("REDIRECT_URI")
2461

2562

@@ -40,29 +77,77 @@ def login(request):
4077

4178

4279
def auth(request):
80+
if not REDIRECT_URI:
81+
return render(
82+
request,
83+
"sso/login.html",
84+
{"error": "configuration_error", "error_description": "REDIRECT_URI is not configured"},
85+
)
86+
87+
login_type = request.POST.get("login_method")
88+
if not login_type:
89+
return render(
90+
request,
91+
"sso/login.html",
92+
{"error": "missing_login_method", "error_description": "Login method is required"},
93+
)
4394

44-
login_type = request.POST["login_method"]
4595
params = {"redirect_uri": REDIRECT_URI, "state": {}}
4696

4797
if login_type == "saml":
48-
params["organization"] = CUSTOMER_ORGANIZATION_ID
98+
if not CUSTOMER_ORGANIZATION_ID:
99+
return render(
100+
request,
101+
"sso/login.html",
102+
{"error": "configuration_error", "error_description": "CUSTOMER_ORGANIZATION_ID is not configured"},
103+
)
104+
params["organization_id"] = CUSTOMER_ORGANIZATION_ID
49105
else:
50106
params["provider"] = login_type
51107

52-
authorization_url = workos.client.sso.get_authorization_url(**params)
108+
client = workos_client if workos_client else get_workos_client()
109+
authorization_url = client.sso.get_authorization_url(**params)
53110

54111
return redirect(authorization_url)
55112

56113

57114
def auth_callback(request):
58-
code = request.GET["code"]
59-
profile = workos.client.sso.get_profile_and_token(code)
60-
p_profile = profile.to_dict()
61-
request.session["p_profile"] = p_profile
62-
request.session["first_name"] = p_profile["profile"]["first_name"]
63-
request.session["raw_profile"] = p_profile["profile"]
64-
request.session["session_active"] = True
65-
return redirect("login")
115+
# Check for error response from WorkOS
116+
if "error" in request.GET:
117+
error = request.GET.get("error")
118+
error_description = request.GET.get("error_description", "An error occurred during authentication")
119+
# Log the error and redirect back to login with error message
120+
return render(
121+
request,
122+
"sso/login.html",
123+
{"error": error, "error_description": error_description},
124+
)
125+
126+
# Get the authorization code
127+
code = request.GET.get("code")
128+
if not code:
129+
return render(
130+
request,
131+
"sso/login.html",
132+
{"error": "missing_code", "error_description": "No authorization code received"},
133+
)
134+
135+
try:
136+
client = workos_client if workos_client else get_workos_client()
137+
profile = client.sso.get_profile_and_token(code)
138+
# In SDK v5+, ProfileAndToken is a Pydantic model - use .dict() to convert to dict
139+
p_profile = profile.dict()
140+
request.session["p_profile"] = p_profile
141+
request.session["first_name"] = p_profile["profile"]["first_name"]
142+
request.session["raw_profile"] = p_profile["profile"]
143+
request.session["session_active"] = True
144+
return redirect("login")
145+
except Exception as e:
146+
return render(
147+
request,
148+
"sso/login.html",
149+
{"error": "authentication_error", "error_description": str(e)},
150+
)
66151

67152

68153
def logout(request):

python-django-sso-example/workos_django/settings.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
https://docs.djangoproject.com/en/3.1/howto/static-files/#configuring-static-files
3434
"""
3535
DEBUG = False
36-
# DEBUG = True
3736

3837
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
3938

0 commit comments

Comments
 (0)