Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .ansible-lint
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
---
# Collection wide lint-file
# DO NOT CHANGE
## Collection wide ansible-lint configuration file.
# Changes for ansible-lint v25.7.0+
# - Always executed from collection root using collection configuration.
# - .ansible-lint-ignore can be used to ignore files, not folders.
## Execution examples:
# ansible-lint
# ansible-lint roles/sap_swpm
# ansible-lint roles/sap_install_media_detect -c roles/sap_install_media_detect/.ansible-lint

exclude_paths:
- .ansible/
- .cache/
- .github/
# - docs/
- changelogs/
- playbooks/
- tests/

enable_list:
- yaml

skip_list:
# We don't want to enforce new Ansible versions for Galaxy:
- meta-runtime[unsupported-version]
Expand Down
10 changes: 0 additions & 10 deletions .github/workflows/.ansible-lint

This file was deleted.

36 changes: 36 additions & 0 deletions .github/workflows/ansible-lint-sap_software_download.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: Ansible Lint - sap_software_download

on:
push:
branches:
- main
- dev
paths:
- 'roles/sap_software_download/**'
pull_request:
branches:
- main
- dev
paths:
- 'roles/sap_software_download/**'

workflow_dispatch:

jobs:
ansible-lint:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v5

# Use @v25 to automatically track the latest release from the year 2025.
# ansible-lint uses Calendar Versioning (e.g., v25.9.0 -> YYYY.MM.PATCH).
# Avoid using @main, which can introduce breaking changes unexpectedly.
- uses: ansible/ansible-lint@v25
with:
# v25.7.0 no longer uses 'working_directory' and role path is set in 'args'.
# Role specific .ansible-lint can be added with argument '-c'.
args: roles/sap_software_download
# Use the shared requirements file from the collection root for dependency context.
requirements_file: ./requirements.yml
22 changes: 16 additions & 6 deletions .github/workflows/ansible-lint.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
name: Ansible Lint
---
name: Ansible Lint - Collection

on: [push, pull_request]
on:
schedule:
# This is 03:05 UTC, which is 5:05 AM in Prague/CEST.
- cron: '5 3 * * 1'

workflow_dispatch:

jobs:
ansible-lint:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Ansible Lint Action
uses: ansible/ansible-lint@v6
# Use @v25 to automatically track the latest release from the year 2025.
# ansible-lint uses Calendar Versioning (e.g., v25.9.0 -> YYYY.MM.PATCH).
# Avoid using @main, which can introduce breaking changes unexpectedly.
- uses: ansible/ansible-lint@v25
with:
# Use the shared requirements file from the collection root for dependency context.
requirements_file: ./requirements.yml
84 changes: 84 additions & 0 deletions .github/workflows/ansible-test-sanity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
# Always check ansible-core support matrix before configuring units matrix.
# https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix

name: Ansible Test - Sanity

on:
schedule:
# This is 01:05 UTC, which is 3:05 AM in Prague/CEST
- cron: '5 3 * * 1'

pull_request:
branches:
- main
- dev

workflow_dispatch:

jobs:
sanity-supported:
runs-on: ubuntu-latest
name: Sanity (Supported Ⓐ${{ matrix.ansible }})
strategy:
fail-fast: false # Disabled so we can see all failed combinations.
# Define a build matrix to test compatibility across multiple Ansible versions.
# Each version listed below will spawn a separate job that runs in parallel.
matrix:
ansible:
- 'stable-2.18' # Python 3.11 - 3.13
- 'stable-2.19' # Python 3.11 - 3.13
- 'devel' # Test against the upcoming development version.

steps:
- uses: actions/checkout@v5

- name: ansible-test - sanity
uses: ansible-community/ansible-test-gh-action@release/v1
with:
ansible-core-version: ${{ matrix.ansible }}
testing-type: sanity

sanity-eol:
runs-on: ubuntu-latest
# This job only runs if the supported tests pass
needs: sanity-supported
name: Sanity (EOL Ⓐ${{ matrix.ansible }}+py${{ matrix.python }})
continue-on-error: true # This entire job is allowed to fail
strategy:
fail-fast: false # Disabled so we can see all failed combinations.
# Define a build matrix to test compatibility across multiple Ansible versions.
# Each version listed below will spawn a separate job that runs in parallel.
matrix:
ansible:
- 'stable-2.14' # Python 3.9 - 3.11
- 'stable-2.15' # Python 3.9 - 3.11
- 'stable-2.16' # Python 3.10 - 3.12
- 'stable-2.17' # Python 3.10 - 3.12
python:
- '3.9'
- '3.10'
- '3.11'
- '3.12'
exclude:
# Exclusions for incompatible Python versions.
- ansible: 'stable-2.14'
python: '3.12'

- ansible: 'stable-2.15'
python: '3.12'

- ansible: 'stable-2.16'
python: '3.9'

- ansible: 'stable-2.17'
python: '3.9'
steps:
- uses: actions/checkout@v5

- name: ansible-test - sanity
uses: ansible-community/ansible-test-gh-action@release/v1
with:
ansible-core-version: ${{ matrix.ansible }}
target-python-version: ${{ matrix.python }}
testing-type: sanity
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ When an SAP User ID (e.g. S-User) is enabled with and part of an SAP Universal I
- the SAP User ID
- the password for login with the SAP Universal ID

In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID Account Password in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts.
In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID `Account Password` in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts.

For further information regarding connection errors, please see the FAQ section [Errors with prefix 'SAP SSO authentication failed - '](./docs/FAQ.md#errors-with-prefix-sap-sso-authentication-failed---).

Expand Down
2 changes: 1 addition & 1 deletion docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The error HTTP 401 refers to either:
- Unauthorized, the SAP User ID being used belongs to an SAP Company Number (SCN) with one or more Installation Number/s which do not have license agreements for these files
- Unauthorized, the SAP User ID being used does not have SAP Download authorizations
- Unauthorized, the SAP User ID is part of an SAP Universal ID and must use the password of the SAP Universal ID
- In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID Account Password in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts.
- In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID `Account Password` in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts.

This is documented under [Execution - Credentials](https://github.com/sap-linuxlab/community.sap_launchpad#requirements-dependencies-and-testing).

Expand Down
77 changes: 65 additions & 12 deletions plugins/module_utils/auth.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,57 @@
from __future__ import absolute_import, division, print_function

__metaclass__ = type

import json
import re
from functools import wraps

from urllib.parse import parse_qs, quote_plus, urljoin
from bs4 import BeautifulSoup
from requests.models import HTTPError

from . import constants as C
from . import exceptions

try:
from bs4 import BeautifulSoup
except ImportError:
HAS_BS4 = False
BeautifulSoup = None
else:
HAS_BS4 = True

try:
from requests.models import HTTPError
except ImportError:
HAS_REQUESTS = False
HTTPError = None
else:
HAS_REQUESTS = True

_GIGYA_SDK_BUILD_NUMBER = None


def require_bs4(func):
# A decorator to check for the 'beautifulsoup4' library before executing a function.
@wraps(func)
def wrapper(*args, **kwargs):
if not HAS_BS4:
raise ImportError("The 'beautifulsoup4' library is required but was not found.")
return func(*args, **kwargs)
return wrapper


def require_requests(func):
# A decorator to check for the 'requests' library before executing a function.
@wraps(func)
def wrapper(*args, **kwargs):
if not HAS_REQUESTS:
raise ImportError("The 'requests' library is required but was not found.")
return func(*args, **kwargs)
return wrapper


@require_requests
@require_bs4
def login(client, username, password):
# Main authentication function.
#
Expand Down Expand Up @@ -57,18 +98,20 @@ def login(client, username, password):
'samlContext': params['samlContext']
}
endpoint, meta = get_sso_endpoint_meta(client, idp_endpoint,
params=context,
allow_redirects=False)
params=context,
allow_redirects=False)

while (endpoint != C.URL_LAUNCHPAD + '/'):
endpoint, meta = get_sso_endpoint_meta(client, endpoint,
data=meta,
headers=C.GIGYA_HEADERS,
allow_redirects=False)
data=meta,
headers=C.GIGYA_HEADERS,
allow_redirects=False)

client.post(endpoint, data=meta, headers=C.GIGYA_HEADERS)


@require_requests
@require_bs4
def get_sso_endpoint_meta(client, url, **kwargs):
# Scrapes an HTML page to find the next SSO form action URL and its input fields.
method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET'
Expand Down Expand Up @@ -100,6 +143,7 @@ def get_sso_endpoint_meta(client, url, **kwargs):
return (endpoint, metadata)


@require_requests
def _get_gigya_login_params(client, url, data):
# Follows a redirect and extracts parameters from the resulting URL's query string.
gigya_idp_res = client.post(url, data=data)
Expand All @@ -109,9 +153,10 @@ def _get_gigya_login_params(client, url, data):
return params


@require_requests
def _gigya_websdk_bootstrap(client, params):
# Performs the initial bootstrap call to the Gigya WebSDK.
page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'],
page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey']
params.update({
'pageURL': page_url,
'sdk': 'js_latest',
Expand All @@ -120,10 +165,11 @@ def _gigya_websdk_bootstrap(client, params):
})

client.get(C.URL_ACCOUNT_CDC_API + '/accounts.webSdkBootstrap',
params=params,
headers=C.GIGYA_HEADERS)
params=params,
headers=C.GIGYA_HEADERS)


@require_requests
def _gigya_login(client, username, password, api_key):
# Performs a login using the standard Gigya accounts.login API.
# This avoids a custom SAP endpoint that triggers password change notifications.
Expand Down Expand Up @@ -154,6 +200,7 @@ def _gigya_login(client, username, password, api_key):
return login_response.get('login_token')


@require_requests
def _get_id_token(client, saml_params, login_token):
# Exchanges a Gigya login token for a JWT ID token.
query_params = {
Expand All @@ -166,6 +213,7 @@ def _get_id_token(client, saml_params, login_token):
return token


@require_requests
def _get_uid(client, saml_params, login_token):
# Retrieves the user's unique ID (UID) using the login token.
query_params = {
Expand All @@ -177,6 +225,7 @@ def _get_uid(client, saml_params, login_token):
return uid


@require_requests
def _get_uid_details(client, uid, id_token):
# Fetches detailed account information for a given UID.
url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}'
Expand All @@ -187,16 +236,18 @@ def _get_uid_details(client, uid, id_token):
return uid_details_response


@require_requests
def _is_uid_linked_multiple_sids(uid_details):
# Checks if a Universal ID (UID) is linked to more than one S-User ID.
accounts = uid_details['accounts']
linked = []
for _, v in accounts.items():
for _account_type, v in accounts.items():
linked.extend(v['linkedAccounts'])

return len(linked) > 1


@require_requests
def _select_account(client, uid, sid, id_token):
# Selects a specific S-User ID when a Universal ID is linked to multiple accounts.
url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}/selectedAccount'
Expand All @@ -207,6 +258,7 @@ def _select_account(client, uid, sid, id_token):
return client.request('PUT', url, headers=headers, json=data)


@require_requests
def _get_sdk_build_number(client, api_key):
# Fetches the gigya.js file to extract and cache the SDK build number.
global _GIGYA_SDK_BUILD_NUMBER
Expand All @@ -224,6 +276,7 @@ def _get_sdk_build_number(client, api_key):
return build_number


@require_requests
def _cdc_api_request(client, endpoint, saml_params, query_params):
# Helper to make requests to the Gigya/CDC API, handling common parameters and errors.
url = '/'.join((C.URL_ACCOUNT_CDC_API, endpoint))
Expand Down Expand Up @@ -251,7 +304,7 @@ def _cdc_api_request(client, endpoint, saml_params, query_params):

error_code = json_response['errorCode']
if error_code != 0:
http_error_msg = '{} Error: {} for url: {}'.format(
http_error_msg = '{0} Error: {1} for url: {2}'.format(
json_response['statusCode'], json_response['errorMessage'], res.url)
raise HTTPError(http_error_msg, response=res)
return json_response
Loading