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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ tests/*_results/
tests/*.log
tests/compliance_test.env
docker-compose.yml
uv.lock
2 changes: 1 addition & 1 deletion keepercommander/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
# Contact: commander@keepersecurity.com
#

__version__ = '18.1.7'
__version__ = '18.1.8'
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,9 @@ def _apply(cls, params, action, folder_uid, recipient, role, expiration,
taken = result.get('action_taken', verb)
if taken == 'already_had_access':
logging.info("%s '%s' already has access", kind, recipient)
elif taken in ('denied', 'already_denied'):
logging.info("%s '%s' inherited access denied on this folder",
kind, recipient)
else:
logging.info("%s share '%s' %s", kind, recipient, verb)
else:
Expand Down
158 changes: 158 additions & 0 deletions keepercommander/commands/pam_cloud/entra_sign_in.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Interactive Microsoft Entra sign-in for a CLI: OAuth 2.0 authorization code flow with PKCE.

The user signs in (including any MFA prompt) in their own browser and Entra redirects back to a
short-lived loopback listener on this machine, which redeems the code for an access token. No
password or MFA code ever passes through Commander. The app registration needs the
``http://localhost`` redirect under "Mobile and desktop applications" and public client flows
enabled.
"""
import base64
import hashlib
import http.server
import json
import logging
import secrets
import threading
import time
import urllib.parse
import webbrowser

import requests

from keepercommander.error import CommandError


logger = logging.getLogger(__name__)

COMMAND = 'pam-cloud-generate-credentials'
DEFAULT_AUTHORITY = 'https://login.microsoftonline.com'
SIGN_IN_TIMEOUT_SECONDS = 300


def _b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')


def pkce_pair():
"""Return (code_verifier, code_challenge) for the S256 method."""
verifier = _b64url(secrets.token_bytes(32))
return verifier, _b64url(hashlib.sha256(verifier.encode('ascii')).digest())


def build_authorize_url(authority, tenant_id, client_id, redirect_uri, scope, state, code_challenge, login_hint=None):
query = {
'client_id': client_id,
'response_type': 'code',
'redirect_uri': redirect_uri,
'response_mode': 'query',
'scope': scope,
'state': state,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
if login_hint:
query['login_hint'] = login_hint
return f'{authority}/{urllib.parse.quote(tenant_id, safe="")}/oauth2/v2.0/authorize?' \
+ urllib.parse.urlencode(query)


def token_claims(token):
"""Return the claims of a JWT without verifying it (it came straight from Entra over TLS)."""
try:
segment = token.split('.')[1]
return json.loads(base64.urlsafe_b64decode(segment + '=' * (-len(segment) % 4)))
except Exception:
return {}


def signed_in_identities(claims):
return {str(claims[key]).strip().lower()
for key in ('upn', 'unique_name', 'preferred_username', 'email') if claims.get(key)}


class _CallbackHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
params = {k: v[0] for k, v in urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).items()}
if 'code' in params or 'error' in params:
self.server.result = params
body = 'Sign-in complete. You can close this window and return to Keeper Commander.'
else:
body = 'Waiting for sign-in.'
payload = body.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(payload)))
self.end_headers()
self.wfile.write(payload)

def log_message(self, *args):
pass


def _wait_for_redirect(server, timeout):
server.result = None
server.timeout = 1
deadline = time.monotonic() + timeout
while server.result is None and time.monotonic() < deadline:
server.handle_request()
return server.result


def sign_in(tenant_id, client_id, scope, login_hint, authority=DEFAULT_AUTHORITY,
timeout=SIGN_IN_TIMEOUT_SECONDS, open_browser=webbrowser.open):
"""Sign `login_hint` in through the system browser and return the token response dict.

Raises CommandError if the sign-in is cancelled, times out, fails, or completes as a
different user than `login_hint`.
"""
server = http.server.HTTPServer(('127.0.0.1', 0), _CallbackHandler)
try:
redirect_uri = f'http://localhost:{server.server_port}'
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(16)
url = build_authorize_url(authority, tenant_id, client_id, redirect_uri, f'{scope} openid profile',
state, challenge, login_hint)

print('Opening your browser to sign in to Microsoft. If it does not open, visit:')
print(f' {url}')
try:
open_browser(url)
except Exception as e:
logger.debug('Could not open a browser: %r', e)

result = _wait_for_redirect(server, timeout)
finally:
server.server_close()

if result is None:
raise CommandError(COMMAND, 'The Microsoft sign-in was not completed in time.')
if result.get('state') != state:
raise CommandError(COMMAND, 'The Microsoft sign-in response did not match the request.')
if 'error' in result:
raise CommandError(COMMAND, f'Microsoft sign-in failed: {result.get("error_description") or result["error"]}')

try:
response = requests.post(
f'{authority}/{urllib.parse.quote(tenant_id, safe="")}/oauth2/v2.0/token',
data={
'grant_type': 'authorization_code',
'client_id': client_id,
'code': result['code'],
'redirect_uri': redirect_uri,
'code_verifier': verifier,
'scope': f'{scope} openid profile',
},
timeout=30)
token = response.json()
except (requests.RequestException, ValueError) as e:
raise CommandError(COMMAND, f'Could not redeem the Microsoft sign-in: {type(e).__name__}')

if response.status_code != 200 or not token.get('access_token'):
description = (token.get('error_description') or token.get('error') or 'unknown error').splitlines()[0]
raise CommandError(COMMAND, f'Microsoft rejected the sign-in: {description}')

expected = (login_hint or '').strip().lower()
identities = signed_in_identities(token_claims(token.get('id_token') or token['access_token']))
if expected and expected not in identities:
raise CommandError(COMMAND, f'The sign-in was completed as a different user than {login_hint}.')
return token
83 changes: 74 additions & 9 deletions keepercommander/commands/pam_cloud/pam_cloud_credentials.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import argparse
import base64
import json
import datetime
import logging

from keepercommander.commands.base import Command, GroupCommand, RecordMixin
from keepercommander.commands.pam_cloud import entra_sign_in
from keepercommander.commands.pam.pam_dto import GatewayAction
from keepercommander.commands.pam.router_helper import dispatch_streamed_access_elevation_action, get_dag_leafs
from keepercommander.commands.pam.vault_target import (
Expand Down Expand Up @@ -74,14 +76,66 @@ def _resolve_config_uid(params, record_uid, configuration):
return _resolve_configuration_record(params, configuration, linked_config_uids=config_uids)


def _config_field(record, label):
"""Value of the config record field called `label` (e.g. tenantId), or ''."""
for field in list(record.fields) + list(record.custom):
if (field.label or '').casefold() == label.casefold():
return field.get_default_value(str) or ''
return ''


# Not narrowed to any one API or application: .default asks for everything the app registration is
# already consented for, against the tenant directory the user lives in.
DEFAULT_SIGN_IN_SCOPE = 'https://graph.microsoft.com/.default'


def _sign_in_locally(config_record, login, client_id_override, scope_override=None):
"""Sign the user in to Entra in their own browser (MFA happens there) and return the
(credential_record, expiration) the Gateway would otherwise have minted."""
tenant_id = _config_field(config_record, 'tenantId')
client_id = client_id_override or _config_field(config_record, 'clientId')
if not tenant_id or not client_id:
raise CommandError('pam-cloud-generate-credentials',
'The PAM configuration needs a tenant ID and client ID (or pass --client-id).')

scope = scope_override or DEFAULT_SIGN_IN_SCOPE
token = entra_sign_in.sign_in(tenant_id, client_id, scope, login_hint=login)

expires_in = int(token.get('expires_in') or 3600)
expiration = (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=expires_in)).isoformat()
return {
'record_type': 'pamUser',
'title': f'Temporary AZURE access token - {login}',
'fields': [
{'type': 'login', 'value': login},
{'type': 'password', 'value': ''},
{'type': 'secret', 'label': 'privatePEMKey', 'value': json.dumps({'accessToken': token['access_token']})},
{'type': 'text', 'label': 'Provider', 'value': 'azure'},
],
'notes': (f"Short-lived AZURE credentials issued by Keeper PAM for '{login}'. "
f"Auto-expires {expiration}. Do not share."),
}, expiration


class PAMCloudGenerateCredentialsCommand(Command):
parser = argparse.ArgumentParser(prog='pam cloud generate-credentials',
description='Generate a temporary cloud access token for a PAM cloud resource, '
'scoped to the logged-in user. The resource\'s own cloud provider '
'mints the credential; the gateway never creates a new account.')
'scoped to the logged-in user; the gateway never creates a new account. '
'Azure: the Gateway grants the access, then you sign in to Microsoft '
'in your browser (MFA works) to receive the token.')
parser.add_argument('record', help='PAM cloud resource (pamCloudResource) UID, title, or folder path')
parser.add_argument('--configuration', '-c', dest='configuration',
help='PAM configuration UID or title, if the resource is linked to more than one')
parser.add_argument('--client-id', dest='client_id',
help='Azure: application (client) ID to sign in with. Defaults to '
'the PAM configuration\'s client ID, which needs a public-client '
'http://localhost redirect.')
parser.add_argument('--scope', dest='scope',
help='Azure: narrow the token to a specific API, e.g. '
'https://management.azure.com/.default. Not needed normally: the default is '
'https://graph.microsoft.com/.default, the tenant the user exists in, with '
'nothing narrowed. The API must be in the app registration\'s permissions.')
parser.add_argument('--duration-seconds', '-d', dest='duration_seconds', type=int,
help='Credential lifetime in seconds (provider-clamped). Leave unset to use the '
'resource\'s own configured elevation time, which also matches how long the '
Expand Down Expand Up @@ -120,6 +174,12 @@ def execute(self, params, **kwargs):
'username': params.user,
'senderPublicKey': sender_public_key,
}

config_record = vault.KeeperRecord.load(params, config_uid)
if isinstance(config_record, vault.TypedRecord) and config_record.record_type == 'pamAzureConfiguration':
# Entra cannot mint a token for a user without that user signing in (MFA included), so the
# Gateway only attaches the access and the requester signs in to Microsoft themselves below.
inputs['clientSideAuth'] = True
duration_seconds = kwargs.get('duration_seconds')
if duration_seconds:
inputs['durationSeconds'] = duration_seconds
Expand All @@ -135,13 +195,18 @@ def execute(self, params, **kwargs):
if not data.get('success', False):
raise CommandError('pam-cloud-generate-credentials', data.get('error') or 'Unknown error')

encrypted_record = data.get('encrypted_record')
if not encrypted_record:
raise CommandError('pam-cloud-generate-credentials',
'The Gateway did not return any credential material.')
if data.get('clientSideAuth'):
credential_record, expiration = _sign_in_locally(
config_record, inputs['username'], kwargs.get('client_id'), kwargs.get('scope'))
else:
encrypted_record = data.get('encrypted_record')
if not encrypted_record:
raise CommandError('pam-cloud-generate-credentials',
'The Gateway did not return any credential material.')

decrypted = crypto.decrypt_ec(base64.b64decode(encrypted_record), private_key)
credential_record = json.loads(decrypted)
decrypted = crypto.decrypt_ec(base64.b64decode(encrypted_record), private_key)
credential_record = json.loads(decrypted)
expiration = data.get('expiration', '')

fields = credential_record.get('fields', [])
login = next((f.get('value') for f in fields if f.get('type') == 'login'), '')
Expand All @@ -152,7 +217,7 @@ def execute(self, params, **kwargs):
print(f' Provider: {provider}')
print(f' Login: {login}')
print(f' Credential: {"**********" if has_secret else "(none)"}')
print(f' Expiration: {data.get("expiration", "")}')
print(f' Expiration: {expiration}')

if kwargs.get('save_record'):
typed_record = vault.TypedRecord()
Expand Down
11 changes: 9 additions & 2 deletions keepercommander/commands/pam_debug/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@
from typing import TYPE_CHECKING

from ..pam_import.record_loader import load_pam_record # noqa: F401 — re-export for pam_debug modules
from ...error import CommandError

if TYPE_CHECKING:
from ...params import KeeperParams
from ...keeper_dag.connection import ConnectionBase


def get_connection(params: KeeperParams) -> ConnectionBase:
if value_to_boolean(os.environ.get("USE_LOCAL_DAG", False)) is False:
use_local_dag = value_to_boolean(os.environ.get("USE_LOCAL_DAG", False))
if use_local_dag and getattr(params, 'service_mode', False):
raise CommandError(
'pam action debug dump',
'The local DAG engine is not available through Service Mode.',
)
if use_local_dag is False:
from ...keeper_dag.connection.commander import Connection as CommanderConnection
# New per-graph endpoints (`/api/user/graph-sync/<graph>/<verb>`) are
# protobuf-only — JSON reads on those routes return empty. Match the
Expand All @@ -21,4 +28,4 @@ def get_connection(params: KeeperParams) -> ConnectionBase:
use_write_protobuf=True)
else:
from ...keeper_dag.connection.local import Connection as LocalConnection
return LocalConnection()
return LocalConnection()
Loading
Loading