Skip to content

Commit 2c556ec

Browse files
Merge pull request #273 from skanct/ck_add_bitbucket_backend
Add oauth2 backend for BitBucket
2 parents 2000fc5 + 4f35ab1 commit 2c556ec

File tree

3 files changed

+309
-0
lines changed

3 files changed

+309
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module: satosa.backends.bitbucket.BitBucketBackend
2+
name: bitbucket
3+
config:
4+
authz_page: bitbucket/auth/callback
5+
base_url: <base_url>
6+
client_config:
7+
client_id:
8+
client_secret:
9+
scope: ["account", "email"]
10+
response_type: code
11+
allow_signup: false
12+
server_info: {
13+
authorization_endpoint: 'https://bitbucket.org/site/oauth2/authorize',
14+
token_endpoint: 'https://bitbucket.org/site/oauth2/access_token',
15+
user_endpoint: 'https://api.bitbucket.org/2.0/user'
16+
}
17+
entity_info:
18+
organization:
19+
display_name:
20+
- ["BitBucket", "en"]
21+
name:
22+
- ["BitBucket", "en"]
23+
url:
24+
- ["https://www.bitbucket.com/", "en"]
25+
ui_info:
26+
description:
27+
- ["Login to a service using your BitBucket credentials", "en"]
28+
display_name:
29+
- ["BitBucket", "en"]
30+

src/satosa/backends/bitbucket.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
OAuth backend for BitBucket
3+
"""
4+
import json
5+
import logging
6+
import requests
7+
8+
from oic.utils.authn.authn_context import UNSPECIFIED
9+
from oic.oauth2.consumer import stateID
10+
11+
from satosa.backends.oauth import _OAuthBackend
12+
from satosa.internal import AuthenticationInformation
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class BitBucketBackend(_OAuthBackend):
18+
"""BitBucket OAuth 2.0 backend"""
19+
20+
logprefix = "BitBucket Backend:"
21+
22+
def __init__(self, outgoing, internal_attributes, config, base_url, name):
23+
"""BitBucket backend constructor
24+
:param outgoing: Callback should be called by the module after the
25+
authorization in the backend is done.
26+
:param internal_attributes: Mapping dictionary between SATOSA internal
27+
attribute names and the names returned by underlying IdP's/OP's as
28+
well as what attributes the calling SP's and RP's expects namevice.
29+
:param config: configuration parameters for the module.
30+
:param base_url: base url of the service
31+
:param name: name of the plugin
32+
:type outgoing:
33+
(satosa.context.Context, satosa.internal.InternalData) ->
34+
satosa.response.Response
35+
:type internal_attributes: dict[string, dict[str, str | list[str]]]
36+
:type config: dict[str, dict[str, str] | list[str] | str]
37+
:type base_url: str
38+
:type name: str
39+
"""
40+
config.setdefault('response_type', 'code')
41+
config['verify_accesstoken_state'] = False
42+
super().__init__(outgoing, internal_attributes, config, base_url,
43+
name, 'bitbucket', 'account_id')
44+
45+
def get_request_args(self, get_state=stateID):
46+
request_args = super().get_request_args(get_state=get_state)
47+
48+
client_id = self.config["client_config"]["client_id"]
49+
extra_args = {
50+
arg_name: arg_val
51+
for arg_name in ["auth_type", "scope"]
52+
for arg_val in [self.config.get(arg_name, [])]
53+
if arg_val
54+
}
55+
extra_args.update({"client_id": client_id})
56+
request_args.update(extra_args)
57+
return request_args
58+
59+
def auth_info(self, request):
60+
return AuthenticationInformation(
61+
UNSPECIFIED, None,
62+
self.config['server_info']['authorization_endpoint'])
63+
64+
def user_information(self, access_token):
65+
url = self.config['server_info']['user_endpoint']
66+
email_url = "{}/emails".format(url)
67+
headers = {'Authorization': 'Bearer {}'.format(access_token)}
68+
resp = requests.get(url, headers=headers)
69+
data = json.loads(resp.text)
70+
if 'email' in self.config['scope']:
71+
resp = requests.get(email_url, headers=headers)
72+
emails = json.loads(resp.text)
73+
data.update({
74+
'email': [e for e in [d.get('email')
75+
for d in emails.get('values')
76+
if d.get('is_primary')
77+
]
78+
],
79+
'email_confirmed': [e for e in [d.get('email')
80+
for d in emails.get('values')
81+
if d.get('is_confirmed')
82+
]
83+
]
84+
})
85+
return data
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import json
2+
from unittest.mock import Mock
3+
from urllib.parse import urlparse, parse_qsl
4+
5+
import pytest
6+
import responses
7+
8+
from saml2.saml import NAMEID_FORMAT_TRANSIENT
9+
10+
from satosa.backends.bitbucket import BitBucketBackend
11+
from satosa.internal import InternalData
12+
13+
BB_USER_RESPONSE = {
14+
"account_id": "bb_id",
15+
"is_staff": False,
16+
"username": "bb_username",
17+
"nickname": "bb_username",
18+
"display_name": "bb_first_name bb_last_name",
19+
"has_2fa_enabled": False,
20+
"created_on": "2019-10-12T09:14:00+0000"
21+
}
22+
BB_USER_EMAIL_RESPONSE = {
23+
"values": [
24+
{
25+
"email": "[email protected]",
26+
"is_confirmed": True,
27+
"is_primary": True
28+
},
29+
{
30+
"email": "[email protected]",
31+
"is_confirmed": True,
32+
"is_primary": False
33+
},
34+
{
35+
"email": "[email protected]",
36+
"is_confirmed": False,
37+
"is_primary": False
38+
}
39+
]
40+
}
41+
BASE_URL = "https://client.example.com"
42+
AUTHZ_PAGE = 'bitbucket'
43+
CLIENT_ID = "bitbucket_client_id"
44+
BB_CONFIG = {
45+
'server_info': {
46+
'authorization_endpoint':
47+
'https://bitbucket.org/site/oauth2/authorize',
48+
'token_endpoint': 'https://bitbucket.org/site/oauth2/access_token',
49+
'user_endpoint': 'https://api.bitbucket.org/2.0/user'
50+
},
51+
'client_secret': 'bitbucket_secret',
52+
'base_url': BASE_URL,
53+
'state_encryption_ key': 'state_encryption_key',
54+
'encryption_key': 'encryption_key',
55+
'authz_page': AUTHZ_PAGE,
56+
'client_config': {'client_id': CLIENT_ID},
57+
'scope': ["account", "email"]
58+
59+
}
60+
BB_RESPONSE_CODE = "the_bb_code"
61+
62+
INTERNAL_ATTRIBUTES = {
63+
'attributes': {
64+
'mail': {'bitbucket': ['email']},
65+
'subject-id': {'bitbucket': ['account_id']},
66+
'displayname': {'bitbucket': ['display_name']},
67+
'name': {'bitbucket': ['display_name']},
68+
}
69+
}
70+
71+
mock_get_state = Mock(return_value="abcdef")
72+
73+
74+
class TestBitBucketBackend(object):
75+
@pytest.fixture(autouse=True)
76+
def create_backend(self):
77+
self.bb_backend = BitBucketBackend(Mock(), INTERNAL_ATTRIBUTES,
78+
BB_CONFIG, "base_url", "bitbucket")
79+
80+
@pytest.fixture
81+
def incoming_authn_response(self, context):
82+
context.path = 'bitbucket/sso/redirect'
83+
state_data = dict(state=mock_get_state.return_value)
84+
context.state[self.bb_backend.name] = state_data
85+
context.request = {
86+
"code": BB_RESPONSE_CODE,
87+
"state": mock_get_state.return_value
88+
}
89+
90+
return context
91+
92+
def setup_bitbucket_response(self):
93+
_user_endpoint = BB_CONFIG['server_info']['user_endpoint']
94+
responses.add(responses.GET,
95+
_user_endpoint,
96+
body=json.dumps(BB_USER_RESPONSE),
97+
status=200,
98+
content_type='application/json')
99+
100+
responses.add(responses.GET,
101+
'{}/emails'.format(_user_endpoint),
102+
body=json.dumps(BB_USER_EMAIL_RESPONSE),
103+
status=200,
104+
content_type='application/json')
105+
106+
def assert_expected_attributes(self):
107+
expected_attributes = {
108+
"subject-id": [BB_USER_RESPONSE["account_id"]],
109+
"name": [BB_USER_RESPONSE["display_name"]],
110+
"displayname": [BB_USER_RESPONSE["display_name"]],
111+
"mail": [BB_USER_EMAIL_RESPONSE["values"][0]["email"]],
112+
}
113+
114+
context, internal_resp = self.bb_backend \
115+
.auth_callback_func \
116+
.call_args[0]
117+
assert internal_resp.attributes == expected_attributes
118+
119+
def assert_token_request(self, request_args, state, **kwargs):
120+
assert request_args["code"] == BB_RESPONSE_CODE
121+
assert request_args["redirect_uri"] == "%s/%s" % (BASE_URL, AUTHZ_PAGE)
122+
assert request_args["state"] == mock_get_state.return_value
123+
assert state == mock_get_state.return_value
124+
125+
def test_register_endpoints(self):
126+
url_map = self.bb_backend.register_endpoints()
127+
expected_url_map = [('^bitbucket$', self.bb_backend._authn_response)]
128+
assert url_map == expected_url_map
129+
130+
def test_start_auth(self, context):
131+
context.path = 'bitbucket/sso/redirect'
132+
internal_request = InternalData(
133+
subject_type=NAMEID_FORMAT_TRANSIENT, requester='test_requester'
134+
)
135+
136+
resp = self.bb_backend.start_auth(context,
137+
internal_request,
138+
mock_get_state)
139+
login_url = resp.message
140+
assert login_url.startswith(
141+
BB_CONFIG["server_info"]["authorization_endpoint"])
142+
expected_params = {
143+
"client_id": CLIENT_ID,
144+
"state": mock_get_state.return_value,
145+
"response_type": "code",
146+
"scope": " ".join(BB_CONFIG["scope"]),
147+
"redirect_uri": "%s/%s" % (BASE_URL, AUTHZ_PAGE)
148+
}
149+
actual_params = dict(parse_qsl(urlparse(login_url).query))
150+
assert actual_params == expected_params
151+
152+
@responses.activate
153+
def test_authn_response(self, incoming_authn_response):
154+
self.setup_bitbucket_response()
155+
156+
mock_do_access_token_request = Mock(
157+
return_value={"access_token": "bb access token"})
158+
self.bb_backend.consumer.do_access_token_request = \
159+
mock_do_access_token_request
160+
161+
self.bb_backend._authn_response(incoming_authn_response)
162+
assert self.bb_backend.name not in incoming_authn_response.state
163+
164+
self.assert_expected_attributes()
165+
self.assert_token_request(**mock_do_access_token_request.call_args[1])
166+
167+
@responses.activate
168+
def test_entire_flow(self, context):
169+
"""
170+
Tests start of authentication (incoming auth req) and receiving auth
171+
response.
172+
"""
173+
responses.add(responses.POST,
174+
BB_CONFIG["server_info"]["token_endpoint"],
175+
body=json.dumps({"access_token": "qwerty",
176+
"token_type": "bearer",
177+
"expires_in": 9999999999999}),
178+
status=200,
179+
content_type='application/json')
180+
self.setup_bitbucket_response()
181+
182+
context.path = 'bitbucket/sso/redirect'
183+
internal_request = InternalData(
184+
subject_type=NAMEID_FORMAT_TRANSIENT, requester='test_requester'
185+
)
186+
187+
self.bb_backend.start_auth(context, internal_request, mock_get_state)
188+
context.request = {
189+
"code": BB_RESPONSE_CODE,
190+
"state": mock_get_state.return_value
191+
}
192+
self.bb_backend._authn_response(context)
193+
assert self.bb_backend.name not in context.state
194+
self.assert_expected_attributes()

0 commit comments

Comments
 (0)