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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,41 @@ land in shell history or process listings.
On Linux, binding port 80 needs root or `CAP_NET_BIND_SERVICE`. Without either,
use `MFILES_GRPC_TOKEN`.

### Signing in once, not on every run

By default every run opens the browser, and an MFA vault asks for MFA each time.
`token-cache = "keyring"` remembers the sign-in instead:

```
pip install "mfiles-grpc[keyring]"
```

```toml
[m-files.tool.grpc]
auth = "sso"
token-cache = "keyring"
```

* The first run signs in through the browser as before. When the IdP issues a
refresh token (the vault's scopes include `offline_access`), it is kept in the
operating system's credential store (Windows Credential Manager, macOS
Keychain, or the Secret Service on Linux) under the host and the vault GUID.
* Later runs trade the refresh token for a new token with one call to the IdP.
There is no browser and no MFA, until the IdP expires or revokes the refresh
token. Then the browser opens again. An IdP that rotates refresh tokens is
handled: the new one replaces the old.
* Only the refresh token is stored. It is never written to a file, a log line or
a `repr`. A credential store that cannot be reached is logged and ignored: the
sign-in goes on without remembering.
* **A refresh token is a credential.** Whoever can read it can sign in as you to
that vault without MFA. The credential store protects it with your operating
system login. Leave this off on a shared account or a machine you do not trust.
* `mfiles-grpc forget-token` removes it. A token in `MFILES_GRPC_TOKEN` always
wins, and `auth = "password"` ignores the setting.
* Two runs refreshing at the same moment can race when the IdP rotates refresh
tokens: the loser's token is already used, and its next run falls back to the
browser. The library does not lock across processes.

## Install

```
Expand Down Expand Up @@ -160,6 +195,7 @@ address = "localhost:4443" # connect here instead, e.g. a capturing proxy
ca-cert = "proxide_ca.crt" # trust these root certificates (PEM) instead of the system's
auth = "sso" # "password" (default) or "sso"; see above
sso-token = "access" # optional: "id" or "access"
token-cache = "keyring" # optional: remember the SSO sign-in; see "Signing in once"
```

### Capturing traffic through a proxy
Expand Down Expand Up @@ -216,6 +252,7 @@ mfiles-grpc capabilities # anonymous; does the host speak gRPC?
mfiles-grpc auth-config # anonymous; the vault's SSO settings
mfiles-grpc login # are the credentials good?
mfiles-grpc check-session # is the session accepted?
mfiles-grpc forget-token # remove the sign-in token-cache remembers
mfiles-grpc structure # object types, classes, custom properties
```

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ dependencies = [
]

[project.optional-dependencies]
# token-cache = "keyring" remembers the SSO sign-in in the OS credential store.
keyring = [
"keyring>=25.0",
]
dev = [
"grpcio-tools>=1.84.0",
"pytest>=9.0.2",
Expand Down
18 changes: 17 additions & 1 deletion src/mfiles_grpc/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
mfiles-grpc auth-config anonymous; shows the vault's SSO (OAuth) settings
mfiles-grpc login logs in and out; proves the credentials
mfiles-grpc check-session logs in and makes one read that needs the session
mfiles-grpc forget-token removes the sign-in that token-cache remembers
mfiles-grpc structure lists object types, classes and property definitions
"""

Expand All @@ -16,7 +17,7 @@

from google.protobuf import json_format

from . import sso, structure
from . import sso, structure, token_cache
from .client import Client, SessionNotAccepted
from .config import load_settings
from .proto import pb
Expand Down Expand Up @@ -75,6 +76,20 @@ def _check_session(args, settings) -> int:
return 0


def _forget_token(args, settings) -> int:
cache = token_cache.open_cache(settings.token_cache, settings.host, settings.vault)
if cache is None:
print('Nothing is remembered: token-cache is not set in the settings file.')
return 0
try:
cache.clear()
except token_cache.TokenCacheError as e:
print(f"Not cleared: {e}", file=sys.stderr)
return 1
print(f"Forgot the remembered sign-in for {settings.host}")
return 0


def _structure(args, settings) -> int:
with Client.connect(settings) as client:
print("Object types:")
Expand Down Expand Up @@ -102,6 +117,7 @@ def main(argv=None) -> int:
commands.add_parser("auth-config").set_defaults(run=_auth_config)
commands.add_parser("login").set_defaults(run=_login)
commands.add_parser("check-session").set_defaults(run=_check_session)
commands.add_parser("forget-token").set_defaults(run=_forget_token)
commands.add_parser("structure").set_defaults(run=_structure)

args = parser.parse_args(argv)
Expand Down
7 changes: 5 additions & 2 deletions src/mfiles_grpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import grpc

from . import sso
from . import sso, token_cache
from .config import ConnectionSettings
from .proto import pb, rpc

Expand Down Expand Up @@ -129,7 +129,10 @@ def connect(cls, settings: ConnectionSettings) -> "Client":
config = sso.discover(client, settings.vault)
if settings.sso_token:
config = dataclasses.replace(config, use_access_token=settings.sso_token == "access")
token = settings.token or sso.acquire_token(config)
# Without a cache the call is exactly what it was before the cache existed.
cache = token_cache.open_cache(settings.token_cache, settings.host, settings.vault)
cache_args = {} if cache is None else {"cache": cache}
token = settings.token or sso.acquire_token(config, **cache_args)
client.log_in_with_token(token, settings.vault, config.plugin_name, config.configuration_scope)
else:
client.log_in(settings.username, settings.password, settings.vault)
Expand Down
15 changes: 14 additions & 1 deletion src/mfiles_grpc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@
ca-cert = "proxy-ca.pem" # trust these root certificates (PEM) instead of the system's
auth = "sso" # "password" (default) or "sso"
sso-token = "access" # optional: "id" or "access"; default as the vault's plugin says
token-cache = "keyring" # optional: remember the SSO sign-in in the OS credential store

gRPC is served on the REST host. With address set, only the connection goes there: the
vault host from rest-api-url is still the name logged in to and the name the server
certificate must carry, so a proxy has to present a certificate for the vault host.

With auth = "sso", a token in the MFILES_GRPC_TOKEN environment variable is used
as it is, instead of signing in through the browser.

With token-cache = "keyring", the browser is needed only for the first sign-in: the refresh
token is kept in the operating system's credential store and traded for a new token each run.
See token_cache.py for what that means for security.
"""

import os
Expand All @@ -30,6 +35,8 @@
from typing import Optional
from urllib.parse import urlparse

from .token_cache import CACHE_KINDS

DEFAULT_PORT = 443
AUTH_METHODS = ("password", "sso")
SSO_TOKEN_KINDS = ("id", "access")
Expand All @@ -48,6 +55,7 @@ class ConnectionSettings:
auth: str = "password"
sso_token: Optional[str] = None
token: Optional[str] = None
token_cache: Optional[str] = None

def __repr__(self) -> str:
# Never let the password or a token reach a log line or a traceback.
Expand All @@ -57,7 +65,8 @@ def hidden(secret: Optional[str]) -> str:
return (f"ConnectionSettings(host={self.host!r}, vault={self.vault!r}, "
f"username={self.username!r}, password={hidden(self.password)}, "
f"port={self.port}, address={self.address!r}, ca_cert={self.ca_cert!r}, "
f"auth={self.auth!r}, sso_token={self.sso_token!r}, token={hidden(self.token)})")
f"auth={self.auth!r}, sso_token={self.sso_token!r}, token={hidden(self.token)}, "
f"token_cache={self.token_cache!r})")

@property
def target(self) -> str:
Expand Down Expand Up @@ -102,6 +111,9 @@ def load_settings(path: str = "client-config.toml") -> ConnectionSettings:
sso_token = grpc_section.get("sso-token")
if sso_token is not None and sso_token not in SSO_TOKEN_KINDS:
raise ValueError(f"sso-token = {sso_token!r}: expected one of {', '.join(SSO_TOKEN_KINDS)}")
token_cache = grpc_section.get("token-cache")
if token_cache is not None and token_cache not in CACHE_KINDS:
raise ValueError(f"token-cache = {token_cache!r}: expected one of {', '.join(CACHE_KINDS)}")
if auth == "password" and (not common.get("username") or not common.get("password")):
raise ValueError('auth = "password" needs username and password in [m-files.tool.common]')

Expand All @@ -116,4 +128,5 @@ def load_settings(path: str = "client-config.toml") -> ConnectionSettings:
auth=auth,
sso_token=sso_token,
token=os.environ.get(TOKEN_ENVIRONMENT_VARIABLE) if auth == "sso" else None,
token_cache=token_cache if auth == "sso" else None,
)
117 changes: 107 additions & 10 deletions src/mfiles_grpc/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse

from .proto import pb
from .token_cache import TokenCache, TokenCacheError
from .values import to_python

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -204,26 +205,58 @@ def _post_form(url: str, form: dict) -> dict:
raise SsoError(f"Token endpoint answered {e.code}: {e.read().decode('utf-8', 'replace')}") from e


def exchange_code(config: OAuthConfig, code: str, verifier: str, redirect_uri: str,
post: PostForm = _post_form) -> str:
def _select_token(config: OAuthConfig, tokens: dict) -> str:
"""
:param redirect_uri: The one sent in the authorization URL; the IdP compares them
:param tokens: A token endpoint answer
:return: The ID token, or the access token when the plugin asks for it
"""
wanted = "access_token" if config.use_access_token else "id_token"
if not tokens.get(wanted):
raise SsoError(f"Token endpoint returned no {wanted}; it returned: {', '.join(sorted(tokens))}")
return tokens[wanted]


def _exchange_code_for_tokens(config: OAuthConfig, code: str, verifier: str, redirect_uri: str,
post: PostForm) -> dict:
form = {
"grant_type": "authorization_code",
"client_id": config.client_id,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
}
if config.client_secret:
form["client_secret"] = config.client_secret
return post(config.token_endpoint, form)


def exchange_code(config: OAuthConfig, code: str, verifier: str, redirect_uri: str,
post: PostForm = _post_form) -> str:
"""
:param redirect_uri: The one sent in the authorization URL; the IdP compares them
:return: The ID token, or the access token when the plugin asks for it
"""
return _select_token(config, _exchange_code_for_tokens(config, code, verifier, redirect_uri, post))


def refresh(config: OAuthConfig, refresh_token: str, post: PostForm = _post_form) -> dict:
"""
Trade a refresh token for new tokens, without a browser.

:param refresh_token: One the IdP issued for this client
:return: The token endpoint's answer. It carries a new refresh token when the IdP rotates them.
:raises SsoError: The IdP refused the token, or answered without the token the plugin wants
"""
form = {
"grant_type": "refresh_token",
"client_id": config.client_id,
"refresh_token": refresh_token,
}
if config.client_secret:
form["client_secret"] = config.client_secret
tokens = post(config.token_endpoint, form)
wanted = "access_token" if config.use_access_token else "id_token"
if not tokens.get(wanted):
raise SsoError(f"Token endpoint returned no {wanted}; it returned: {', '.join(sorted(tokens))}")
return tokens[wanted]
_select_token(config, tokens)
return tokens


def loopback_address(redirect_uri: str) -> tuple[int, str]:
Expand Down Expand Up @@ -255,14 +288,78 @@ def _listen(port: int, handler) -> list[http.server.HTTPServer]:
return servers


def _cached_call(what: str, call, default=None):
"""
:param what: What the call does, for the log
:param call: The credential store call to make
:return: Its result, or default when the store fails. A broken store must not stop a sign-in.
"""
try:
return call()
except TokenCacheError as e:
log.warning("Cannot %s the remembered sign-in: %s", what, e)
return default


def _remember(cache: TokenCache, tokens: dict, previous: Optional[str] = None) -> None:
"""
:param tokens: A token endpoint answer
:param previous: The refresh token that was just used, if any
"""
issued = tokens.get("refresh_token")
if issued:
if issued != previous:
if _cached_call("save", lambda: (cache.save(issued), True)[1], default=False):
log.info("Remembered the sign-in%s", " (the IdP rotated the refresh token)" if previous else "")
elif previous is None:
log.info("The IdP issued no refresh token, so this sign-in is not remembered")


def _token_from_cache(config: OAuthConfig, cache: TokenCache, post: PostForm) -> Optional[str]:
"""
:return: A token from the remembered refresh token, or None when none is remembered or the IdP
refuses it, in which case it is forgotten
"""
remembered = _cached_call("read", cache.load)
if not remembered:
return None
try:
tokens = refresh(config, remembered, post)
except SsoError as e:
log.info("The remembered sign-in no longer works, signing in again: %s", e)
_cached_call("clear", cache.clear)
return None
log.info("Signed in with the remembered sign-in, without the browser")
_remember(cache, tokens, previous=remembered)
return _select_token(config, tokens)


def acquire_token(config: OAuthConfig, open_browser: Callable[[str], object] = webbrowser.open,
post: PostForm = _post_form, timeout: float = 300) -> str:
post: PostForm = _post_form, timeout: float = 300,
cache: Optional[TokenCache] = None) -> str:
"""
Sign in through the browser.
Get a token for LogIn: from the remembered sign-in when there is one, else through the browser.

:param timeout: Seconds to wait for the user to finish signing in
:param cache: Where the refresh token is remembered, or None to sign in through the browser every
time. A sign-in through the browser is remembered when the IdP issues a refresh token.
:return: See exchange_code()
"""
if cache is not None:
token = _token_from_cache(config, cache, post)
if token:
return token
tokens = _sign_in_with_browser(config, open_browser, post, timeout)
if cache is not None:
_remember(cache, tokens)
return _select_token(config, tokens)


def _sign_in_with_browser(config: OAuthConfig, open_browser: Callable[[str], object],
post: PostForm, timeout: float) -> dict:
"""
:return: The token endpoint's answer to the authorization code
"""
port, path = loopback_address(config.redirect_uri)
redirect_uri = config.redirect_uri
verifier, challenge = pkce_pair()
Expand Down Expand Up @@ -300,4 +397,4 @@ def log_message(self, format, *args):
server.shutdown()
server.server_close()
code = parse_callback(answer["query"], state)
return exchange_code(config, code, verifier, redirect_uri, post=post)
return _exchange_code_for_tokens(config, code, verifier, redirect_uri, post)
Loading
Loading