Currently, I have a working API that uses Connexion and receives an OpenAPI specification:
connexion_app.add_api(
"openapi.yaml",
options={"swagger_ui": False},
validate_responses=True,
strict_validation=True, # Changing this also didn't help
)
A response gets validated in the following order:
Check if API-Key is valid
Validate if the request body contains all necessary parameters
Validate message-signature
Handle request and send response
The verification of the API-Key is done via the OpenAPI spec:
securitySchemes:
apiKeyAuth:
type: apiKey
in: header
name: API-Key
x-apikeyInfoFunc: server.security.check_api_key
security:
- apiKeyAuth: []
The validation is also done via the OpenAPI spec.
The signature gets verified in the endpoint:
if not verify_signature(kwargs):
abort(401, "Signature could not be verified")
Where verify_signature is basically this:
def verify_signature(request) -> bool:
"""Calculate the signature using the header and data."""
signature = re.findall(r'"([A-Za-z0-9+/=]+)"', connexion.request.headers.get("Message-Signature", ""))
created = re.findall(r"created=(\d+)", connexion.request.headers.get("Message-Signature", ""))
if len(signature) == 0:
abort(401, "No valid Signature found.")
if len(created) == 0:
abort(401, "No valid created timestamp found.")
signature = signature[0]
created = int(created[0])
method, path, host, api_key, content_type = _get_attributes_from_request()
message = create_signature_message(request["body"], created, method, path, host, api_key, content_type)
recreated_signature = _encode_message(message)
return recreated_signature == str(signature)
For security purposes I would like to swap 2. and 3.:
Check if API-Key is valid
Validate message-signature
Validate if the request body contains all necessary parameters
Handle request and send response
The problem is that Connexion validates the body before I get to my endpoint in which I execute my Python code such as verify_signature.
I tried adding the following to my OpenAPI.yaml:
signatureAuth:
type: http
scheme: basic
x-basicInfoFunc: server.security.verify_signature
security:
- apiKeyAuth: []
signatureAuth: []
But I think this is the wrong approach since I think this is only used as a simple verification method and I get the following error message:
No authorization token provided.
Now to my question:
Is there a way to execute a function which receives the whole request that gets executed before Connexion validates the body?
Yes you can use the Connexion before_request annotation so it runs a function on a new request before validating the body. Here's an example that logs the headers and content:
import connexion
import logging
from flask import request
logger = logging.getLogger(__name__)
conn_app = connexion.FlaskApp(__name__)
#conn_app.app.before_request
def before_request():
for h in request.headers:
logger.debug('header %s', h)
logger.debug('data %s', request.get_data())
Related
I am trying to start an eBay API in Python and I can't find a single answer as to how to get an API key with eBay's new requirements of "Account Deletion/Closure Notifications." Here's the link: https://developer.ebay.com/marketplace-account-deletion
Specifically, I am told that "Your Keyset is currently disabled" because I have not completed whatever process is needed for this marketplace account deletion/closure notification.
The problems?
I have no idea if I need this.
I have no idea how to actually do this.
Re: 1. It looks like this is for anyone who stores user data. I don’t think that’s me intentionally because I really just want to get sold data and current listings, but is it actually me?
Re: 2. I don’t understand how to validate it and send back the proper responses. I’ve gotten quite good at python but I’m lost here.
eBay forums are completely useless and I see no one with an answer to this. Any help is greatly appreciated.
Re: 1. Same. Here's my interpretation: In order to use their APIs, you need to provide (and configure) your own API, so they can communicate with you —programatically— and tell you what users have asked to have their accounts/data deleted.
Re: 2. To handle their GET and POST requests, I guess you'll need to configure a website's URL as an API endpoint. In Django, I might use something like this (untested) code:
import hashlib
import json
from django.http import (
HttpResponse,
JsonResponse,
HttpResponseBadRequest
)
def your_api_endpoint(request):
"""
API Endpoint to handle the verification's challenge code and
receive eBay's Marketplace Account Deletion/Closure Notifications.
"""
# STEP 1: Handle verification's challenge code
challengeCode = request.GET.get('challenge_code')
if challengeCode is not None:
# Token needs to be 32-80 characters long
verificationToken = "your-token-012345678901234567890123456789"
# URL needs to use HTTPS protocol
endpoint_url = "https://your-domain.com/your-endpoint"
# Hash elements need to be ordered as follows
m = hashlib.sha256((challengeCode+verificationToken+endpoint_url).encode('utf-8'))
# JSON field needs to be called challengeResponse
return JsonResponse({"challengeResponse": m.hexdigest()}, status=200)
# STEP 2: Handle account deletion/closure notification
elif request.method == 'POST':
notification_details = json.loads(request.body)
# Verify notification is actually from eBay
# ...
# Delete/close account
# ...
# Acknowledge notification reception
return HttpResponse(status=200)
else:
return HttpResponseBadRequest()
If you find the answer to question number one, please do let me know.
Re: 1. You need to comply with eBay's Marketplace Account Deletion/Closure Notification workflow if you are storing user data into your own database. For example, using eBay's Buy APIs, you may get access to what users are selling on eBay (for ex. an eBay feed of products). If those eBay sellers decide they want to remove all of their personal data from eBay's database, eBay is requesting you remove their data from your database as well. If you are NOT storing any eBay user data into your database, you do not need to comply. Here is where you can find more info: https://partnerhelp.ebay.com/helpcenter/s/article/Complying-with-the-eBay-Marketplace-Account-Deletion-Closure-Notification-workflow?language=en_US
Re: 2. To be honest I've spent days trying to figure this out in Python (Django), but I have a solution now and am happy to share it with whoever else comes across this issue. Here's my solution:
import os
import json
import base64
import hashlib
import requests
import logging
from OpenSSL import crypto
from rest_framework import status
from rest_framework.views import APIView
from django.http import JsonResponse
logger = logging.getLogger(__name__)
class EbayMarketplaceAccountDeletion(APIView):
"""
This is required as per eBay Marketplace Account Deletion Requirements.
See documentation here: https://developer.ebay.com/marketplace-account-deletion
"""
# Ebay Config Values
CHALLENGE_CODE = 'challenge_code'
VERIFICATION_TOKEN = os.environ.get('VERIFICATION_TOKEN')
# ^ NOTE: You can make this value up so long as it is between 32-80 characters.
ENDPOINT = 'https://example.com/ebay_marketplace_account_deletion'
# ^ NOTE: Replace this with your own endpoint
X_EBAY_SIGNATURE = 'X-Ebay-Signature'
EBAY_BASE64_AUTHORIZATION_TOKEN = os.environ.get('EBAY_BASE64_AUTHORIZATION_TOKEN')
# ^ NOTE: Here's how you can get your EBAY_BASE64_AUTHORIZATION_TOKEN:
# import base64
# base64.b64encode(b'{CLIENT_ID}:{CLIENT_SECRET}')
def __init__(self):
super(EbayMarketplaceAccountDeletion, self).__init__()
def get(self, request):
"""
Get challenge code and return challengeResponse: challengeCode + verificationToken + endpoint
:return: Response
"""
challenge_code = request.GET.get(self.CHALLENGE_CODE)
challenge_response = hashlib.sha256(challenge_code.encode('utf-8') +
self.VERIFICATION_TOKEN.encode('utf-8') +
self.ENDPOINT.encode('utf-8'))
response_parameters = {
"challengeResponse": challenge_response.hexdigest()
}
return JsonResponse(response_parameters, status=status.HTTP_200_OK)
def post(self, request):
"""
Return 200 status code and remove from db.
See how to validate the notification here:
https://developer.ebay.com/api-docs/commerce/notification/overview.html#use
"""
# Verify notification is actually from eBay #
# 1. Use a Base64 function to decode the X-EBAY-SIGNATURE header and retrieve the public key ID and signature
x_ebay_signature = request.headers[self.X_EBAY_SIGNATURE]
x_ebay_signature_decoded = json.loads(base64.b64decode(x_ebay_signature).decode('utf-8'))
kid = x_ebay_signature_decoded['kid']
signature = x_ebay_signature_decoded['signature']
# 2. Call the getPublicKey Notification API method, passing in the public key ID ("kid") retrieved from the
# decoded signature header. Documentation on getPublicKey:
# https://developer.ebay.com/api-docs/commerce/notification/resources/public_key/methods/getPublicKey
public_key = None
try:
ebay_verification_url = f'https://api.ebay.com/commerce/notification/v1/public_key/{kid}'
oauth_access_token = self.get_oauth_token()
headers = {
'Authorization': f'Bearer {oauth_access_token}'
}
public_key_request = requests.get(url=ebay_verification_url, headers=headers, data={})
if public_key_request.status_code == 200:
public_key_response = public_key_request.json()
public_key = public_key_response['key']
except Exception as e:
message_title = "Ebay Marketplace Account Deletion: Error calling getPublicKey Notfication API."
logger.error(f"{message_title} Error: {e}")
return JsonResponse({}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 3. Initialize the cryptographic library to perform the verification with the public key that is returned from
# the getPublicKey method. If the signature verification fails, an HTTP status of 412 Precondition Failed is returned.
pkey = crypto.load_publickey(crypto.FILETYPE_PEM, self.get_public_key_into_proper_format(public_key))
certification = crypto.X509()
certification.set_pubkey(pkey)
notification_payload = request.body
signature_decoded = base64.b64decode(signature)
try:
crypto.verify(certification, signature_decoded, notification_payload, 'sha1')
except crypto.Error as e:
message_title = f"Ebay Marketplace Account Deletion: Signature Invalid. " \
f"The signature is invalid or there is a problem verifying the signature. "
logger.warning(f"{message_title} Error: {e}")
return JsonResponse({}, status=status.HTTP_412_PRECONDITION_FAILED)
except Exception as e:
message_title = f"Ebay Marketplace Account Deletion: Error performing cryptographic validation."
logger.error(f"{message_title} Error: {e}")
return JsonResponse({}, status=status.HTTP_412_PRECONDITION_FAILED)
# Take appropriate action to delete the user data. Deletion should be done in a manner such that even the
# highest system privilege cannot reverse the deletion #
# TODO: Replace with your own data removal here
# Acknowledge notification reception
return JsonResponse({}, status=status.HTTP_200_OK)
def get_oauth_token(self):
"""
Returns the OAuth Token from eBay which can be used for making other API requests such as getPublicKey
"""
url = 'https://api.ebay.com/identity/v1/oauth2/token'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': f"Basic {self.EBAY_BASE64_AUTHORIZATION_TOKEN}"
}
payload = 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope'
request = requests.post(url=url, headers=headers, data=payload)
data = request.json()
return data['access_token']
#staticmethod
def get_public_key_into_proper_format(public_key):
"""
Public key needs to have \n in places to be properly assessed by crypto library.
"""
return public_key[:26] + '\n' + public_key[26:-24] + '\n' + public_key[-24:]
This is how I am dealing with the ebay notification requirement using Python3 cgi. Because bytes are sent, cannot use cgi.FieldStorage()
import os
import sys
import hashlib
import json
from datetime import datetime
from html import escape
import cgi
import cgitb
import io
include_path = '/var/domain_name/www'
sys.path.insert(0, include_path)
cgitb.enable(display=0, logdir=f"""{include_path}/tmp_errors""") # include_path is OUTDIR
dt_now = datetime.now()
current_dt_now = dt_now.strftime("%Y-%m-%d_%H-%M-%S")
def enc_print(string='', encoding='utf8'):
sys.stdout.buffer.write(string.encode(encoding) + b'\n')
html = ''
challengeCode = ''
# GET
myQuery = os.environ.get('QUERY_STRING')
if myQuery.find('=') != -1:
pos = myQuery.find('=')
var_name = myQuery[:pos]
var_val = myQuery[pos+1:]
challengeCode = var_val
# POST
if os.environ.get('CONTENT_LENGTH') != None:
totalBytes=int(os.environ.get('CONTENT_LENGTH'))
reqbytes=io.open(sys.stdin.fileno(),"rb").read(totalBytes)
if challengeCode != '' :
"""
API Endpoint to handle the verification's challenge code and
receive eBay's Marketplace Account Deletion/Closure Notifications.
"""
# STEP 1: Handle verification's challenge code
# Token needs to be 32-80 characters long
verificationToken = "0123456789012345678901234567890123456789" #sample token
# URL needs to use HTTPS protocol
endpoint = "https://domain_name.com/ebay/notification.py" # sample endpoint
# Hash elements need to be ordered as follows
m = hashlib.sha256( (challengeCode+verificationToken+endpoint).encode('utf-8') )
# JSON field needs to be called challengeResponse
enc_print("Content-Type: application/json")
enc_print("Status: 200 OK")
enc_print()
enc_print('{"challengeResponse":"' + m.hexdigest() + '"}')
exit()
else :
#html += 'var length:' + str(totalBytes) + '\n'
html += reqbytes.decode('utf-8') + '\n'
# STEP 2: Handle account deletion/closure notification
# Verify notification is actually from eBay
# ...
# Delete/close account
# ...
# Acknowledge notification reception
with open( f"""./notifications/{current_dt_now}_user_notification.txt""", 'w') as f:
f.write(html)
enc_print("Content-Type: application/json")
enc_print("Status: 200 OK")
enc_print()
exit()
I've been trying #José Matías Arévalo code. It works except "STEP 2" branch - Django returns 403 error. This is because of by default Django uses CSRF middleware (Cross Site Request Forgery protection). To avoid 403 error we need to marks a view as being exempt from the protection as described here https://docs.djangoproject.com/en/dev/ref/csrf/#utilities so add couple strings in code:
from django.views.decorators.csrf import csrf_exempt
#csrf_exempt
def your_api_endpoint(request):
And in my case I use url "https://your-domain.com/your-endpoint/" with slash symbol "/" at the end of url. Without this slash eBay doesn't confirm subscription.
I am using Flask and this is the code I have used:
from flask import Flask, request
import hashlib
# Create a random verification token, it needs to be 32-80 characters long
verification_token = 'a94cbd68e463cb9780e2008b1f61986110a5fd0ff8b99c9cba15f1f802ad65f9'
endpoint_url = 'https://dev.example.com'
app = Flask(__name__)
# There will be errors if you just use '/' as the route as it will redirect eBays request
# eBay will send a request to https://dev.example.com?challenge_code=123
# The request will get redirected by Flask to https://dev.example.com/?challenge_code=123 which eBay will not accept
endpoint = endpoint_url + '/test'
# The Content-Type header will be added automatically by Flask as 'application/json'
#app.route('/test')
def test():
code = request.args.get('challenge_code')
print('Requests argument:', code)
code = code + token + endpoint
code = code.encode('utf-8')
code = hashlib.sha256(code)
code = code.hexdigest()
print('Hexdigest:', code)
final = {"challengeResponse": code}
return final
## To run locally first use this:
# app.run(port=29)
I am very new to Azure Function Apps and OAuth so please bear with me.
My Setup
I have an Azure Function App with a simple python-function doing nothing else but printing out the request headers:
import logging
import azure.functions as func
def main(req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
name = req.params.get('name')
if not name:
try:
req_body = req.get_json()
except ValueError:
pass
else:
name = req_body.get('name')
if name:
aadIdToken = req.headers.get('X-MS-TOKEN-AAD-ID-TOKEN')
aadAccessToken = req.headers.get('X-MS-TOKEN-AAD-ACCESS-TOKEN')
principalID = req.headers.get('X-MS-CLIENT-PRINCIPAL-ID')
principalName = req.headers.get('X-MS-CLIENT-PRINCIPAL-NAME')
idProviderId = req.headers.get('X-MS-CLIENT-PRINCIPAL-IDP')
aadRefreshToken = req.headers.get('X-MS-TOKEN-AAD-REFRESH-TOKEN')
clientPrincipal = req.headers.get('X-MS-CLIENT-PRINCIPAL')
result = "\n"
myDict = sorted(dict(req.headers))
for key in myDict:
result += f"{key} = {dict(req.headers)[key]}\n"
return func.HttpResponse(
f"Hello, {name}. How are you ? Doing well ?"\
f"\n\nHere is some data concerning your Client principal:"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL-ID: {principalID}"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL-NAME: {principalName}"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL-IDP: {idProviderId}"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL: {clientPrincipal}"\
f"\n\nHere is some data concerning your AAD-token:"\
f"\nThis is your X-MS-TOKEN-AAD-ID-TOKEN: {aadIdToken}"\
f"\nThis is your X-MS-TOKEN-AAD-ACCESS-TOKEN: {aadAccessToken}"\
f"\nThis is your X-MS-TOKEN-AAD-REFRESH-TOKEN: {aadRefreshToken}"\
f"\n\n\nresult: {result}"\
)
else:
return func.HttpResponse(
"This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.",
status_code=200
)
I followed this guide to let the user authenticate via EasyAuth before calling the function.
This seems to work fine. When accessing the function via browser I am redirected to sign-in. After successful sign-in I am then redirected again and the HTTP response is printed out in the browser. As I am able to access X-MS-CLIENT-PRINCIPAL-ID and X-MS-CLIENT-PRINCIPAL-NAME I suppose the authentication was successful. However when printing out the whole request header I did not find a X-MS-TOKEN-AAD-REFRESH-TOKEN, X-MS-TOKEN-AAD-ACCESS-TOKEN or X-MS-TOKEN-AAD-ID-TOKEN.
This is the output (output too large; below the output shown in the screenshot I can see the header content):
First half of my output
My question
What I am trying to do now is to access the groups assigned to the logged-in user via the python code of the function to further authorize his request (e.g. "user can only execute the function when group xyz is assigned, else he will be prompted 'not allowed'").
To achieve this I added the "groups"-claim to the Token Configuration of my App Registration.
From what I understand accessing the user groups via a function coded in .NET is easily possible by using the ClaimsPrinciple object (source).
How would I be able to access the user assigned groups via python code?
Is that possible?
Am I understanding something completely wrong?
Followup:
One thing that I do not understand by now, is that I can see an id_token in the callback-http-request of the browser-debuggger when accessing the function via browser for the first time (to trigger sign in):
Browser debugger: id_token in callback-request
When I decrypted that token using jwt.io I was able to see some IDs of assigned user groups which seems to be exactly what I want to access via the python code.
Re-loading the page (I suppose the request then uses the already authenticated browser session) makes the callback disappear.
The header X-MS-CLIENT-PRINCIPAL contains the same claims as the id_token. So if we want to get the group claim, we can base64 decode the header.
For example
My code
import logging
import azure.functions as func
import base64
def main(req: func.HttpRequest) -> func.HttpResponse:
logging.info('Python HTTP trigger function processed a request.')
name = req.params.get('name')
if not name:
try:
req_body = req.get_json()
except ValueError:
pass
else:
name = req_body.get('name')
if name:
aadAccessToken = req.headers.get('X-MS-TOKEN-AAD-ACCESS-TOKEN')
principalID = req.headers.get('X-MS-CLIENT-PRINCIPAL-ID')
principalName = req.headers.get('X-MS-CLIENT-PRINCIPAL-NAME')
idProviderId = req.headers.get('X-MS-CLIENT-PRINCIPAL-IDP')
aadRefreshToken = req.headers.get('X-MS-TOKEN-AAD-REFRESH-TOKEN')
clientPrincipal = req.headers.get('X-MS-CLIENT-PRINCIPAL')
clientPrincipal= base64.b64decode(clientPrincipal)
result = "\n"
myDict = sorted(dict(req.headers))
for key in myDict:
result += f"{key} = {dict(req.headers)[key]}\n"
return func.HttpResponse(
f"Hello, {name}. How are you ? Doing well ?"\
f"\n\nHere is some data concerning your Client principal:"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL-ID: {principalID}"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL-NAME: {principalName}"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL-IDP: {idProviderId}"\
f"\nThis is your X-MS-CLIENT-PRINCIPAL: {clientPrincipal}"\
f"\n\nHere is some data concerning your AAD-token:"\
f"\nThis is your X-MS-TOKEN-AAD-ID-TOKEN: {aadIdToken}"\
f"\nThis is your X-MS-TOKEN-AAD-ACCESS-TOKEN: {aadAccessToken}"\
f"\nThis is your X-MS-TOKEN-AAD-REFRESH-TOKEN: {aadRefreshToken}"\
f"\n\n\nresult: {result}"\
)
else:
return func.HttpResponse(
"This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.",
status_code=200
)
The flow to this admin app is basically:
User goes to /admin.
#azure/msal-react checks if the user is logged in and if not redirects them to login.
When the accessToken, idToken, and oid have been received on the FE, they are sent to my API (/api) where they are validated.
If the tokens are validated, and verified as belonging to the oid that came from the FE, then a JWT is issued for the user indicating they are authenticated with the API as well.
The JWT is sent back to the FE where having both the MSAL isAuthenticated() and their API JWT isApiAuthenticated() indicates they are fully authenticated and it renders the FE components.
I'm trying to write unit tests for the what I have in the aad.py (bottom).
The issue I'm seeing is that I need to retrieve and send it a legit MSAL access_token, id_token, and oid. That means I actually need to log in a user against our AAD.
I do have a dummy user to use for this purpose, but I'm not seeing in the Python MSAL documentation how to sign the user in without interaction and just submit username and password.
Suggestions for how to write unit tests to test? Do I need to mockup something else, or should I be able to send a dummy user's username and password with Python MSAL?
# Python libraries
import json
# Third-party dependencies
import jwt
import requests
from base64 import b64decode, b64encode
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from config import settings
def ms_graph(access_token):
"""
Takes the access_token from the FE and uses it query MS Graph for user
information.
Returns an object.
Takes one argument:
access_token: sent from FE.
"""
graph_response = requests.get( # Use token to call downstream service
settings.GRAPH_URI,
headers={'Authorization': 'Bearer ' + access_token},)
return json.loads(graph_response.text)
def validate_token(token):
"""
Used to decode the token sent from the FE. The result is
a verified signature that contains information about the user and tokens.
Returns an object.
Takes one argument:
token: The idToken sent from the FE that needs to be validated.
"""
# Get a list of the possible public keys from the JWKS_URI endpoint
jwkeys = requests.get(settings.JWKS_URI).json()['keys']
# Extract the 'kid' from the unverified header to get the public key
token_key_id = jwt.get_unverified_header(token)['kid']
# Get the object corresponding to the 'kid' key
jwk = [key for key in jwkeys if key['kid'] == token_key_id][0]
# Encode the 'x5c' to eventually get the public key for decoding
der_cert = b64decode(jwk['x5c'][0])
cert = x509.load_der_x509_certificate(der_cert, default_backend())
public_key = cert.public_key()
pem_key = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Finally, validate and decode the token
token_claims = jwt.decode(
token,
pem_key,
algorithms=['RS256'],
audience=settings.CLIENT_ID
)
return token_claims
def validate_user(access_token, id_token, client_oid):
"""
Takes the id_token sent from the FE and validates the signature.
The validated signature also contains an oid claim which is used to verify
the oid sent from the FE.
Returns an object containing:
{
"user_validated": bool,
"graph_response": object
}
Takes two arguments:
access_token: access_token sent from the FE.
id_token: the id_token sent from the FE.
client_oid: the oid sent from the FE.
"""
# Decode the token to retrieve claims
id_token_payload = validate_token(id_token)
# Check if the claim oid and matches the oid sent from the FE
if id_token_payload['oid'] == client_oid:
graph_response = ms_graph(access_token)
res = dict()
res['user_validated'] = graph_response['id'] == client_oid
res['graph_response'] = graph_response
return res
If you really really want to obtain tokens for unit tests with username and password then look at acquire_token_by_username_password() and its example in MS docs.
But there are reasons to not let unit tests use the real MS Graph API:
dependency on network connection can make unit tests run slow or fail, even if your code still works fine,
storing credentials in repository is dangerous, keeping them properly is difficult,
the dummy account can be locked,
numerous test calls can cause throttling,
it is kind of unpolite to abuse the infrastructure with non-productive requests.
The suggestion for unit tests is usual: isolate test cases, then mock and patch any external dependencies not related to code being tested.
MSAL uses requests for calling the cloud, and there is a great package for mocking its work: meet responses. Prevent MSAL for sending out any requests and mock OpenID configuration call:
import responses
import time
import unittest
MS_OPENID_CONFIG = {
"authorization_endpoint": "https://login.microsoftonline.com/common/"
"oauth2/v2.0/authorize",
"token_endpoint": "https://login.microsoftonline.com/common/oauth2/"
"v2.0/token",
}
MS_TOKENS = {
'id_token_claims': {
'exp': time.time() + 60,
'oid': '00000000-0000-0000-0000-000000000000',
'upn': 'test_user_1_'
},
'id_token': {},
'access_token': {}
}
class BaseTest(unittest.TestCase):
def afterSetUp(self):
self.responses = responses.RequestsMock()
self.responses.start()
self.responses.add(
responses.GET,
'https://login.microsoftonline.com/common/v2.0/'
'.well-known/openid-configuration',
json=MS_OPENID_CONFIG
)
# add_calback() allows modifying mock token in runtime
self.responses.add_callback(
responses.POST,
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
callback=lambda request: (200, {}, json.dumps(MS_TOKENS)),
content_type='application/json',
)
self.addCleanup(self.responses.stop)
self.addCleanup(self.responses.reset)
For testing validate_token() you can bundle pre-generated test RSA key pair:
openssl genrsa -out test_rsa_private.pem 512
openssl rsa -in test_rsa_private.pem -pubout -outform PEM -out test_rsa_public.pem
Then you can mock response to settings.JWKS_URI in the same way as OpenID config, returning test public key with known kid.
If you'd prefer to save time on signing test tokens, then just mock jwt signature verification to always return True:
from unittest import Mock, patch
def test_validate_token(self)
...
with patch('jwt.algorithms.RSAAlgorithm.verify', new=Mock(return_value=True)):
# jwt.decode(at, key=open('test_rsa_public.pem').read(), algorithms=['RS256'], options={'verify_exp': False, 'verify_aud': False})
validate_token(test_token)
And so on, unit test should only test the code of unit itself, everything around can be mocked as needed. Read the source of MSAL and jwt. Don't mock what looks like implementation details, they might change in future versions.
I'm having an issue retrieving an Azure Managed Identity access token from my Function App. The function gets a token then accesses a Mysql database using that token as the password.
I am getting this response from the function:
9103 (HY000): An error occurred while validating the access token. Please acquire a new token and retry.
Code:
import logging
import mysql.connector
import requests
import azure.functions as func
def main(req: func.HttpRequest) -> func.HttpResponse:
def get_access_token():
URL = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fossrdbms-aad.database.windows.net&client_id=<client_id>"
headers = {"Metadata":"true"}
try:
req = requests.get(URL, headers=headers)
except Exception as e:
print(str(e))
return str(e)
else:
password = req.json()["access_token"]
return password
def get_mysql_connection(password):
"""
Get a Mysql Connection.
"""
try:
con = mysql.connector.connect(
host='<host>.mysql.database.azure.com',
user='<user>#<db>',
password=password,
database = 'materials_db',
auth_plugin='mysql_clear_password'
)
except Exception as e:
print(str(e))
return str(e)
else:
return "Connected to DB!"
password = get_access_token()
return func.HttpResponse(get_mysql_connection(password))
Running a modified version of this code on a VM with my managed identity works. It seems that the Function App is not allowed to get an access token. Any help would be appreciated.
Note: I have previously logged in as AzureAD Manager to the DB and created this user with all privileges to this DB.
Edit: No longer calling endpoint for VMs.
def get_access_token():
identity_endpoint = os.environ["IDENTITY_ENDPOINT"] # Env var provided by Azure. Local to service doing the requesting.
identity_header = os.environ["IDENTITY_HEADER"] # Env var provided by Azure. Local to service doing the requesting.
api_version = "2019-08-01" # "2018-02-01" #"2019-03-01" #"2019-08-01"
CLIENT_ID = "<client_id>"
resource_requested = "https%3A%2F%2Fossrdbms-aad.database.windows.net"
# resource_requested = "https://ossrdbms-aad.database.windows.net"
URL = f"{identity_endpoint}?api-version={api_version}&resource={resource_requested}&client_id={CLIENT_ID}"
headers = {"X-IDENTITY-HEADER":identity_header}
try:
req = requests.get(URL, headers=headers)
except Exception as e:
print(str(e))
return str(e)
else:
try:
password = req.json()["access_token"]
except:
password = str(req.text)
return password
But now I am getting this Error:
{"error":{"code":"UnsupportedApiVersion","message":"The HTTP resource that matches the request URI 'http://localhost:8081/msi/token?api-version=2019-08-01&resource=https%3A%2F%2Fossrdbms-aad.database.windows.net&client_id=<client_idxxxxx>' does not support the API version '2019-08-01'.","innerError":null}}
Upon inspection this seems to be a general error. This error message is propagated even if it's not the underlying issue. Noted several times in Github.
Is my endpoint correct now?
For this problem, it was caused by the wrong endpoint you request for the access token. We can just use the endpoint http://169.254.169.254/metadata/identity..... in azure VM, but if in azure function we can not use it.
In azure function, we need to get the IDENTITY_ENDPOINT from the environment.
identity_endpoint = os.environ["IDENTITY_ENDPOINT"]
The endpoint is like:
http://127.0.0.1:xxxxx/MSI/token/
You can refer to this tutorial about it, you can also find the python code sample in the tutorial.
In my function code, I also add the client id of the managed identity I created in the token_auth_uri but I'm not sure if the client_id is necessary here (In my case, I use user-assigned identity but not system-assigned identity).
token_auth_uri = f"{identity_endpoint}?resource={resource_uri}&api-version=2019-08-01&client_id={client_id}"
Update:
#r "Newtonsoft.Json"
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
public static async Task<IActionResult> Run(HttpRequest req, ILogger log)
{
string resource="https://ossrdbms-aad.database.windows.net";
string clientId="xxxxxxxx";
log.LogInformation("C# HTTP trigger function processed a request.");
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(String.Format("{0}/?resource={1}&api-version=2019-08-01&client_id={2}", Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT"), resource,clientId));
request.Headers["X-IDENTITY-HEADER"] = Environment.GetEnvironmentVariable("IDENTITY_HEADER");
request.Method = "GET";
HttpWebResponse response = (HttpWebResponse)request.GetResponse();
StreamReader streamResponse = new StreamReader(response.GetResponseStream());
string stringResponse = streamResponse.ReadToEnd();
log.LogInformation("test:"+stringResponse);
string name = req.Query["name"];
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
dynamic data = JsonConvert.DeserializeObject(requestBody);
name = name ?? data?.name;
return name != null
? (ActionResult)new OkObjectResult($"Hello, {name}")
: new BadRequestObjectResult("Please pass a name on the query string or in the request body");
}
For your latest issue, where you are seeing UnsupportedApiVersion, it is probably this issue: https://github.com/MicrosoftDocs/azure-docs/issues/53726
Here are a couple of options that worked for me:
I am assuming you are hosting the Function app on Linux. I noticed that ApiVersion 2017-09-01 works, but you need to make additional changes (instead of "X-IDENTITY-HEADER", use "secret" header). And also use a system-assigned managed identity for your function app, and not a user assigned identity.
When I hosted the function app on Windows, I didn't have the same issues. So if you want to use an user-assigned managed identity, you can try this option instead. (with the api-version=2019-08-01, and X-IDENTITY-HEADER.
I am working in a project and I am trying to get the access token to use the DocuSign API, but the call to get the oauth userinfo does not work.
Authentication type: JWT Grant
Python SDK: docusign-python-client
The code:
class BaseDocusign:
api_client = None
_token_received = False
expiresTimestamp = 0
account = None
def __init__(self):
BaseDocusign.api_client = ApiClient()
def token_is_expired(self):
current_time = int(round(time.time()))
return (current_time + DOCUSIGN_EXPIRE_TIME - 10) > BaseDocusign.expiresTimestamp
def check_token(self):
if not BaseDocusign._token_received or self.token_is_expired():
self.update_token()
def update_token(self):
client = BaseDocusign.api_client
client.request_jwt_user_token(
DOCUSIGN_CLIENT_ID,
DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_AUTH_SERVER,
DOCUSIGN_PRIVATE_KEY,
DOCUSIGN_EXPIRE_TIME
)
if BaseDocusign.account is None:
account = self.get_account_info(client)
print account
BaseDocusign._token_received = True
BaseDocusign.expiresTimestamp = (int(round(time.time())) + DOCUSIGN_EXPIRE_TIME)
def get_account_info(self, client):
client.host = DOCUSIGN_AUTH_SERVER
response = client.call_api("/oauth/userinfo", "GET", response_type="object")
if len(response) > 1 and 200 > response[1] > 300:
raise Exception("can not get user info: %d".format(response[1]))
accounts = response[0]['accounts']
target = target_account_id
if target is None or target == "FALSE":
# Look for default
for acct in accounts:
if acct['is_default']:
return acct
# Look for specific account
for acct in accounts:
if acct['account_id'] == target:
return acct
raise Exception("User does not have access to account {target}\n")
When I run it:
a = BaseDocusign()
a.update_token()
The access token is generated:
{"access_token":"eyJ0eXAiOiJNVCIsImFsZyI6IlJTMjU2Iiwia2lkIjoiNjgxODVmZjEtNGU1MS00Y2U5LWFmMWMtNjg5ODEyMjAzMzE3In0.AQkAAAABAAsADQAkAAAAZjczYjYxMmMtOGI3Ny00YjRjLWFkZTQtZTI0ZWEyYjY4MTEwIgAkAAAAZjczYjYxMmMtOGI3Ny00YjRjLWFkZTQtZTI0ZWEyYjY4MTEwBwAAq89LFJXXSAgAAOvyWVeV10gLAB8AAABodHRwczovL2FjY291bnQtZC5kb2N1c2lnbi5jb20vDAAkAAAAZjczYjYxMmMtOGI3Ny00YjRjLWFkZTQtZTI0ZWEyYjY4MTEwGAABAAAABQAAABIAAQAAAAYAAABqd3RfYnI.f_XW63iL5ABts-gq48ciWKQnaYyNiIEG9rC_CpnyWo0Hzf-B_G3hIRUWJzD1Yiyyy4pKm_8-zoalsoqANcMeXsjwBTCMlXIhc216ZWa6nHR6CheRbfTHM6bJ1LKwRdmnpwLywu_qiqrEwEOlZkwH_GzSSP9piUtpCmhgdZY1GFnG2u9JU_3jd8nKN87PE_cn2sjD3fNMRHQXjnPeHPyBZpC171TyuEvQFKCbV5QOwiVXmZbE9Aa_unC-xXvvJ2cA3daVaUBHoasXUxo5CZDNb9aDxtQkn5GCgQL7JChL7XAfrgXAQMOb-rEzocBpPJKHl6chBNiFcl-gfFWw2naomA","token_type":"Application","expires_in":28800}
But when try to get the account info, the call fails:
{"error":"internal_server_error","reference_id":"f20e360c-185d-463e-9f0b-ce95f38fe711"}
To do this, I call to the get_account_info function and it calls to the endpoint oauth/userinfo, but the call fails.
response = client.call_api("/oauth/userinfo", "GET", response_type="object")
# Response: {"error":"internal_server_error","reference_id":"f20e360c-185d-463e-9f0b-ce95f38fe711"}
To do this example, I need the variable account_id and according to this example, the get_account_info function gets it.
I have also tried to do what the web says (step4) to get user information and the answer is:
curl --request GET https://account-d.docusign.com/oauth/userinfo--header "Authorization: Bearer eyJ0eXAiOiJNVCIsImFsZyI6IlJTMjU2Iiwia2lkIjoiNjgxODVmZjEtNGU1MS00Y2U5LWFmMWMtNjg5ODEyMjAzMzE3In0.AQoAAAABAAUABwAAYWSFlJrXSAgAAMko55ya10gCAP-ftnA70YROvfpqFSh7j7kVAAEAAAAYAAEAAAAFAAAADQAkAAAAZjczYjYxMmMtOGI3Ny00YjRjLWFkZTQtZTI0ZWEyYjY4MTEwIgAkAAAAZjczYjYxMmMtOGI3Ny00YjRjLWFkZTQtZTI0ZWEyYjY4MTEwEgABAAAABgAAAGp3dF9iciMAJAAAAGY3M2I2MTJjLThiNzctNGI0Yy1hZGU0LWUyNGVhMmI2ODExMA.YHFoD2mQbwh8rdiPi8swg9kO9srlDyJcpqUo8XI5tdZki2I_Nla-qb9VaD4gAy8tSXVSY7unRjfClFDAqC8Ur73caHuZo7tN5tIKmXi6C3VzPWPGFJtsceKNEGMqwznw6OBVuPQG0IGlRjXK37Ur1nILLUWKb7w6O5Uz6y0e5uR8sxzZWh1adm2zHqd6khiQuAFB9vG2sS3jaudtck1qV6HRB_kARvUie1zglvHydc42Nc_o5GtIm3sGrqW7rio3YpHVX39nTKM-28kjOvPSNwzXp3IlZtaxuB6EdexrECH19nIaNbCe29LrdpzreRMyjEwwM309bOaKJ1KV82NbTQ"
# Response
<html><head><title>Object moved</title></head><body>
<h2>Object moved to here.</h2>
</body></html>
curl: (3) URL using bad/illegal format or missing URL
Thanks for all :)
Just looking into the code, the return is "acct", a dictionary. So you need to use account['account_id']
I found this full example: https://github.com/docusign/eg-01-python-jwt
And in here: https://github.com/docusign/eg-01-python-jwt/blob/master/example_base.py#L44
you see how they are passing the account_id
Hopefully this helps. good luck
You must use request_jwt_user_token not request_jwt_application_token
See the code example: https://github.com/docusign/eg-01-python-jwt/blob/master/example_base.py#L34
request_jwt_application_token is only for some of the DocuSign organization APIs.
Added
From the comment:
I have changed the call to request_jwt_user_token and I get another token, but it still fails. The response is {"error":"internal_server_error","reference_id":"846114d0-1bcd-47a6-ba23-317049b54d00"}
Answer:
You're calling the /oauth/userinfo API method. But the Authorization header was not included.
One way is to set the Authorization explicitly:
client.set_default_header("Authorization", "Bearer " + ds_access_token)
In your case, the SDK is supposed to set it for you. It could be that you're using a new client object, an older SDK version, or some other issue.
I just downloaded the eg-01-python-jwt code example repo and it worked fine. I suggest that you download the example app and get it running first, then update the app to your needs.
Also, check the version of the Python SDK you're using:
pip3 show docusign_esign
Name: docusign-esign
Version: 3.0.0
Summary: DocuSign REST API
...
Location: /usr/local/lib/python3.7/site-packages
...
This error can be also caused by using an expired token (use the refresh end point to get a new one)