diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f665a33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +sf_private_key.pem +test.py +auth_test.py +test/* + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/Lambda/Dockerfile b/Lambda/Dockerfile deleted file mode 100644 index f03302f..0000000 --- a/Lambda/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 - -# Install the function's dependencies using file requirements.txt -# from your project folder. -RUN yum install -y \ -gcc \ -gcc-c++ \ -Cython \ -make \ -libxml2 \ -libxslt \ -xmlsec1 \ -xmlsec1-devel \ -xmlsec1-openssl \ -libtool-ltdl-devel - -COPY requirements.txt . -RUN pip3 install -r requirements.txt --target "${LAMBDA_TASK_ROOT}" - -# Copy function code -COPY lambda_function/* ${LAMBDA_TASK_ROOT}/ - -# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) -CMD [ "lambda_function.lambda_handler" ] \ No newline at end of file diff --git a/Lambda/lambda_function.py b/Lambda/lambda_function.py deleted file mode 100644 index 6b36022..0000000 --- a/Lambda/lambda_function.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 - -import base64 -import json -import sys -import os -import requests -import xmlsec -import boto3 -from lxml import etree -from datetime import datetime, timedelta - - -region = os.environ.get('AWS_REGION') -secret_id= os.environ.get('SECRET_ID') -template_file = 'sf_saml_template.xml' -private_keyfile = '/tmp/successfactors-private.pem' - -def get_secret(region, secret_name, session): - - client = session.client( - service_name='secretsmanager', - region_name=region - ) - - try: - get_secret_value_response = client.get_secret_value( - SecretId=secret_name - ) - - if 'SecretString' in get_secret_value_response: - secret = get_secret_value_response['SecretString'] - - except Exception as x: - print(x) - sys.exit(1) - - return secret - - -def get_access_token(sf_url, company_id, client_id, assertion): - - token_request = dict( - client_id=client_id, - company_id=company_id, - grant_type='urn:ietf:params:oauth:grant-type:saml2-bearer', - assertion=assertion - ) - response = requests.post(f"{sf_url}/oauth/token", data=token_request) - token_data = response.json() - return token_data['access_token'] - - -def generate_assertion(sf_root_url, user_id, client_id, template_file): - issue_instant = datetime.utcnow() - auth_instant = issue_instant - not_valid_before = issue_instant - timedelta(minutes=10) - not_valid_after = issue_instant + timedelta(minutes=10) - - audience = 'www.successfactors.com' - - context = dict( - issue_instant=issue_instant.isoformat(), - auth_instant=auth_instant.isoformat(), - not_valid_before=not_valid_before.isoformat(), - not_valid_after=not_valid_after.isoformat(), - sf_root_url=sf_root_url, - audience=audience, - user_id=user_id, - client_id=client_id, - session_id='mock_session_index', - ) - saml_template = open(template_file).read() - - return saml_template.format(**context) - - -def sign_assertion(xml_string, private_key): - key = xmlsec.Key.from_file(private_key, xmlsec.KeyFormat.PEM) - - root = etree.fromstring(xml_string) - signature_node = xmlsec.tree.find_node(root, xmlsec.Node.SIGNATURE) - - sign_context = xmlsec.SignatureContext() - sign_context.key = key - sign_context.sign(signature_node) - - return etree.tostring(root) - - -def auth(sf_url, sf_company_id, sf_oauth_client_id, - sf_admin_user, sf_saml_private_key, template_file): - - unsigned_assertion = generate_assertion(sf_url, - sf_admin_user, - sf_oauth_client_id, - template_file) - - signed_assertion = sign_assertion(unsigned_assertion, sf_saml_private_key) - signed_assertion_b64 = base64.b64encode(signed_assertion).replace(b'\n', b'') - access_token = get_access_token(sf_url, - sf_company_id, - sf_oauth_client_id, - signed_assertion_b64) - - return access_token - - -def lambda_handler(event, context): - - session = boto3.session.Session() - - print(event) - - if event['rawPath'] == '/token': - body = json.loads(event['body']) - sf_url = body['odata_url'] - sf_company_id = body['company_id'] - sf_oauth_client_id = body['oauth_client_id'] - sf_admin_user = body['admin_user'] - - private_key = get_secret(region, - secret_id, - session) - - with open(private_keyfile, 'w') as f: - f.write(private_key) - - token = auth(sf_url, sf_company_id, sf_oauth_client_id, sf_admin_user, - private_keyfile, template_file) - - payload = { - "token": token - } - - return { - 'statusCode': 200, - 'body': json.dumps(payload) - } diff --git a/Lambda/requirements.txt b/Lambda/requirements.txt deleted file mode 100644 index 3e0594e..0000000 --- a/Lambda/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests -lxml -xmlsec \ No newline at end of file diff --git a/README.md b/README.md index 4a1ad18..f755dcb 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,62 @@ -# SuccessFactors + Python -Authenticate with and use the SAP SuccessFactors API with Python. +# SuccessFactors Auth +Authenticate with the SAP SuccessFactors API with OAuth2 and Python. -## Authentcation - -### How to use +## How to use 1. Create an OAuth application in SuccessFactors. 2. Download the private key and copy the Client ID. -3. Clone this repo and copy `sf_auth.py` and `sf_saml_template.xml` into your Python project directory. -4. Import `sf_auth` into your project. -5. Install all the requirements listed in `requirements.txt` in this repo. -6. Call the `sf_auth.auth()` function in your Python project. You'll need to pass the following parameters: +3. Install the Python module: + ``` shell + pip install successfactors_auth + ``` +4. Import `successfactors_auth` into your Python >=3.9 project. +5. Call the `successfactors_auth.auth()` function in your Python project. You'll need to pass the following parameters: - `sf_url`: Base API url of your SuccessFactors instance, e.g. "https://api55.sapsf.eu". - `sf_company_id`: SuccessFactors company ID. - `sf_oauth_client_id`: The Client ID for the OAuth application you created earlier. - `sf_admin_user`: An admin user in SuccessFactors that has access to the OAuth application. - `sf_saml_private_key`: Path to the private key file you downloaded when you created the OAuth application. - - `template_file`: Path to the template file from this repo. - Example: - ``` python - #!/usr/bin/env python +### Example - import requests - import sf_auth +``` python +#!/usr/bin/env python - sf_url = 'https://your.base.url.com' - sf_company_id = 'your-company-id' - sf_oauth_client_id = 'OAUTH-CLIENT-ID' - sf_admin_user = 'your_admin_user' - sf_saml_private_key = 'your_app_private_key.pem' - template_file = 'sf_saml_template.xml' +import requests +import successfactors_auth - token = sf_auth.auth(sf_url, sf_company_id, sf_oauth_client_id, sf_admin_user, sf_saml_private_key, sf_saml_template) +sf_url = 'https://your.base.url.com' +sf_company_id = 'your-company-id' +sf_oauth_client_id = 'your_app_client_id' +sf_admin_user = 'your_admin_user' +sf_saml_private_key = 'your_app_private_key.pem' - headers = { - "Accept: application/json", - f"Authorization: {token}" - } +token = successfactors_auth.auth( + sf_url, + sf_company_id, + sf_oauth_client_id, + sf_admin_user, + sf_saml_private_key, + sf_saml_template + ) - request = requests.get(f"{sf_url}/User", headers=headers) +headers = { + "Accept: application/json", + f"Authorization: {token}" +} - print(request.json()) - ``` +request = requests.get(f"{sf_url}/User", headers=headers) +user = request.json() -### Using as an AWS Lambda function w/ API Gateway +print(user) +``` -Coming soon... \ No newline at end of file +## Background + +I wrote this module because I was forced to deal with the *horrific* SAP SuccessFactors API at my job, and I wanted to make sure other devs/sysadmins wouldn't have to feel the pain that I felt. + +Once you get authenticated, getting the information you want is a whole new level of suffering. I hope to publish some more examples in the form of a blog post or docs in this repo. + +## Contributing + +All contributions welcome! Feel free to file an [issue](https://github.com/skoobasteeve/successfactors_auth/issues) or open a [pull request](https://github.com/skoobasteeve/successfactors_auth/pulls). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e76af11 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "successfactors_auth" +version = "0.0.5" +authors = [ + { name="Ray Lyon", email="ray@raylyon.net" }, +] +description = "Authenticate to the SuccessFactors API." +readme = "README.md" +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/skoobasteeve/successfactors_auth" +"Bug Tracker" = "https://github.com/skoobasteeve/successfactors_auth/issues" diff --git a/src/successfactors_auth/__init__.py b/src/successfactors_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sf_auth.py b/src/successfactors_auth/auth.py similarity index 73% rename from sf_auth.py rename to src/successfactors_auth/auth.py index 72447df..831866a 100644 --- a/sf_auth.py +++ b/src/successfactors_auth/auth.py @@ -10,39 +10,44 @@ Bearer token. Derived from: https://github.com/mtrdesign/python-saml-example -This script requires the following additional files: --SAML template XML +This module requires the following additional files: -Private key file for a previously created SuccessFactors OAuth2 application Required packages: -pip install requests lxml xmlsec +requests +lxml +xmlsec Example: #!/usr/bin/env python3 -import sf_auth +import successfactors_auth -token = sf_auth.auth( +token = successfactors_auth.auth( SF_URL, SF_COMPANY_ID, SF_OAUTH_CLIENT_ID, SF_ADMIN_USER, SF_OAUTH_PRIVATE_KEY_FILE, - SAML_TEMPLATE_FILE ) ''' import base64 +from datetime import datetime, timedelta +from importlib import resources as impresources import requests import xmlsec from lxml import etree -from datetime import datetime, timedelta + +from . import templates -# Send POST request to SuccessFactors containing the generated -# SAML assertion and other details, then receive a token in response -def get_access_token(sf_url, company_id, client_id, assertion): - +def get_access_token(sf_url: str, company_id: str, client_id: str, + assertion: str) -> str: + """ + Send POST request to SuccessFactors containing the generated + SAML assertion and other details, then receive a token in response + """ # Request body token_request = dict( client_id=client_id, @@ -50,13 +55,17 @@ def get_access_token(sf_url, company_id, client_id, assertion): grant_type='urn:ietf:params:oauth:grant-type:saml2-bearer', assertion=assertion ) - response = requests.post(f"{sf_url}/oauth/token", data=token_request) + response = requests.post(f"{sf_url}/oauth/token", data=token_request, + timeout=15) token_data = response.json() return token_data['access_token'] -# Generate SAML assertion from the template XML -def generate_assertion(sf_root_url, user_id, client_id, template_file): +def generate_assertion(sf_root_url: str, user_id: str, client_id: str, + template_file: str) -> str: + """ + Generate SAML assertion from the template XML + """ # Calculate valid time values for the assertion's validity issue_instant = datetime.utcnow() auth_instant = issue_instant @@ -78,14 +87,16 @@ def generate_assertion(sf_root_url, user_id, client_id, template_file): session_id='mock_session_index', ) # Open the template file - saml_template = open(template_file).read() + saml_template = open(template_file, encoding="utf-8").read() # Fill the values into the template and return in return saml_template.format(**context) -# Sign the SAML assertion using a private key file -def sign_assertion(xml_string, private_key): +def sign_assertion(xml_string: str, private_key: str) -> str: + """ + Sign the SAML assertion using a private key file + """ # Import key file key = xmlsec.Key.from_file(private_key, xmlsec.KeyFormat.PEM) @@ -102,8 +113,14 @@ def sign_assertion(xml_string, private_key): return etree.tostring(root) -def auth(sf_url, sf_company_id, sf_oauth_client_id, - sf_admin_user, sf_saml_private_key, template_file): +def auth(sf_url: str, sf_company_id: str, sf_oauth_client_id: str, + sf_admin_user: str, sf_saml_private_key: str) -> str: + """ + Request an API access token by generating a signed SAML assertion + and using it to authenticate with SuccessFactors. + """ + + template_file = impresources.files(templates) / 'sf_saml_template.xml' # Generate SAML assertion XML from template file unsigned_assertion = generate_assertion(sf_url, diff --git a/sf_saml_template.xml b/src/successfactors_auth/templates/sf_saml_template.xml similarity index 100% rename from sf_saml_template.xml rename to src/successfactors_auth/templates/sf_saml_template.xml