kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/cli/app.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Command-line interface for kstlib.
|
|
2
|
+
|
|
3
|
+
This module provides the CLI commands using Typer and Rich for enhanced terminal output.
|
|
4
|
+
Available commands:
|
|
5
|
+
- info: Display package information and logo
|
|
6
|
+
- version: Show package version
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# pylint: disable=redefined-builtin
|
|
10
|
+
# Reason: Rich.print is imported to override builtin print for enhanced output
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich import print
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from kstlib import meta
|
|
20
|
+
from kstlib.cli.commands.auth import register_cli as register_auth_cli
|
|
21
|
+
from kstlib.cli.commands.config import register_cli as register_config_cli
|
|
22
|
+
from kstlib.cli.commands.ops import register_cli as register_ops_cli
|
|
23
|
+
from kstlib.cli.commands.rapi import register_cli as register_rapi_cli
|
|
24
|
+
from kstlib.cli.commands.secrets import register_cli as register_secrets_cli
|
|
25
|
+
from kstlib.cli.commands.secrets import shred as secrets_shred
|
|
26
|
+
from kstlib.cli.common import console
|
|
27
|
+
from kstlib.logging import LogManager, get_logger, init_logging
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(add_completion=False, name=meta.__app_name__)
|
|
30
|
+
|
|
31
|
+
# Global logger instance (initialized in main callback)
|
|
32
|
+
_cli_logger: LogManager | None = None
|
|
33
|
+
|
|
34
|
+
# Valid log levels
|
|
35
|
+
LOG_LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
36
|
+
|
|
37
|
+
# Verbose flag mapping: -v=INFO, -vv=DEBUG, -vvv=TRACE
|
|
38
|
+
VERBOSE_LEVELS = {
|
|
39
|
+
0: "WARNING", # Default
|
|
40
|
+
1: "INFO", # -v
|
|
41
|
+
2: "DEBUG", # -vv
|
|
42
|
+
3: "TRACE", # -vvv
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version_callback(value: bool) -> None:
|
|
47
|
+
"""Display version and exit if requested.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
value: True if --version flag was passed.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
typer.Exit: Always exits after showing version.
|
|
54
|
+
"""
|
|
55
|
+
if value:
|
|
56
|
+
print(f"{meta.__version__}")
|
|
57
|
+
raise typer.Exit()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_cli_logger() -> logging.Logger:
|
|
61
|
+
"""Get the CLI logger instance.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The CLI logger. Uses the global kstlib logger if initialized
|
|
65
|
+
via --log-level, otherwise returns a standard logger.
|
|
66
|
+
"""
|
|
67
|
+
return get_logger("cli")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.callback()
|
|
71
|
+
def main( # pylint: disable=unused-argument
|
|
72
|
+
version: bool | None = typer.Option(
|
|
73
|
+
None,
|
|
74
|
+
"--version",
|
|
75
|
+
help="Show the application's version and exit.",
|
|
76
|
+
callback=_version_callback,
|
|
77
|
+
is_eager=True,
|
|
78
|
+
),
|
|
79
|
+
log_level: Annotated[
|
|
80
|
+
str | None,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--log-level",
|
|
83
|
+
"-l",
|
|
84
|
+
help="Set logging level (TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL).",
|
|
85
|
+
case_sensitive=False,
|
|
86
|
+
),
|
|
87
|
+
] = None,
|
|
88
|
+
log_file: Annotated[
|
|
89
|
+
bool,
|
|
90
|
+
typer.Option(
|
|
91
|
+
"--log-file",
|
|
92
|
+
help="Enable file logging (writes to ./logs/kstlib.log by default).",
|
|
93
|
+
),
|
|
94
|
+
] = False,
|
|
95
|
+
verbose: Annotated[
|
|
96
|
+
int,
|
|
97
|
+
typer.Option(
|
|
98
|
+
"--verbose",
|
|
99
|
+
"-v",
|
|
100
|
+
count=True,
|
|
101
|
+
help="Increase verbosity (-v=INFO, -vv=DEBUG, -vvv=TRACE).",
|
|
102
|
+
),
|
|
103
|
+
] = 0,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Initialize the root Typer app and handle --version eagerly."""
|
|
106
|
+
global _cli_logger
|
|
107
|
+
|
|
108
|
+
# Determine log level (priority: --log-level > -v > default)
|
|
109
|
+
if log_level is not None:
|
|
110
|
+
# Explicit --log-level takes precedence
|
|
111
|
+
level = log_level.upper()
|
|
112
|
+
if level not in LOG_LEVELS:
|
|
113
|
+
console.print(f"[red]Invalid log level: {log_level}[/]")
|
|
114
|
+
console.print(f"[dim]Valid levels: {', '.join(LOG_LEVELS)}[/]")
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
elif verbose > 0:
|
|
117
|
+
# -v/-vv/-vvv flags
|
|
118
|
+
level = VERBOSE_LEVELS.get(min(verbose, 3), "TRACE")
|
|
119
|
+
else:
|
|
120
|
+
level = "WARNING" # Default: only warnings and errors
|
|
121
|
+
|
|
122
|
+
# Determine output mode
|
|
123
|
+
output = "both" if log_file else "console"
|
|
124
|
+
|
|
125
|
+
# Always initialize logging so handlers are configured
|
|
126
|
+
_cli_logger = init_logging(
|
|
127
|
+
config={
|
|
128
|
+
"console": {"level": level},
|
|
129
|
+
"file": {"level": level},
|
|
130
|
+
"output": output,
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if log_level is not None or verbose > 0:
|
|
135
|
+
source = "--log-level" if log_level is not None else f"-{'v' * verbose}"
|
|
136
|
+
_cli_logger.debug("CLI logging initialized", level=level, source=source)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command()
|
|
140
|
+
def info(
|
|
141
|
+
full: bool = typer.Option(
|
|
142
|
+
False,
|
|
143
|
+
"--full",
|
|
144
|
+
"-f",
|
|
145
|
+
help="Show full information about the application.",
|
|
146
|
+
),
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Display package information and logo.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
full: If True, show detailed package metadata including author, license, etc.
|
|
152
|
+
"""
|
|
153
|
+
print(meta.__logo__)
|
|
154
|
+
|
|
155
|
+
if full:
|
|
156
|
+
_data = [
|
|
157
|
+
("Name", meta.__app_name__),
|
|
158
|
+
("Version", meta.__version__),
|
|
159
|
+
("Description", meta.__description__),
|
|
160
|
+
("Author", meta.__author__),
|
|
161
|
+
("Email", meta.__email__),
|
|
162
|
+
("URL", meta.__url__),
|
|
163
|
+
("Keywords", ", ".join(meta.__keywords__)),
|
|
164
|
+
("Classifiers", "\n".join(meta.__classifiers__)),
|
|
165
|
+
("License Type", meta.__license_type__),
|
|
166
|
+
("License", meta.__license__),
|
|
167
|
+
("", ""),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
table = Table(show_header=False, show_lines=False, title=None, box=None)
|
|
171
|
+
table.add_column(justify="right")
|
|
172
|
+
table.add_column(justify="left")
|
|
173
|
+
|
|
174
|
+
for row in _data:
|
|
175
|
+
table.add_row(f"[light_salmon1]{row[0]}[/]", row[1])
|
|
176
|
+
|
|
177
|
+
console.print(table)
|
|
178
|
+
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
_version_callback(True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
register_auth_cli(app)
|
|
185
|
+
register_ops_cli(app)
|
|
186
|
+
register_rapi_cli(app)
|
|
187
|
+
register_secrets_cli(app)
|
|
188
|
+
register_config_cli(app)
|
|
189
|
+
|
|
190
|
+
# Expose shred as a top-level command for convenience.
|
|
191
|
+
app.command()(secrets_shred)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
app()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""CLI commands for OAuth2/OIDC authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .login import login
|
|
8
|
+
from .logout import logout
|
|
9
|
+
from .providers import providers
|
|
10
|
+
from .status import status
|
|
11
|
+
from .token import token
|
|
12
|
+
from .whoami import whoami
|
|
13
|
+
|
|
14
|
+
auth_app = typer.Typer(help="Manage OAuth2/OIDC authentication.")
|
|
15
|
+
|
|
16
|
+
# Register commands on the auth_app
|
|
17
|
+
auth_app.command()(login)
|
|
18
|
+
auth_app.command()(logout)
|
|
19
|
+
auth_app.command()(status)
|
|
20
|
+
auth_app.command()(token)
|
|
21
|
+
auth_app.command()(whoami)
|
|
22
|
+
auth_app.command()(providers)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_cli(app: typer.Typer) -> None:
|
|
26
|
+
"""Register the auth sub-commands on the root Typer app."""
|
|
27
|
+
app.add_typer(auth_app, name="auth")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"auth_app",
|
|
32
|
+
"login",
|
|
33
|
+
"logout",
|
|
34
|
+
"providers",
|
|
35
|
+
"register_cli",
|
|
36
|
+
"status",
|
|
37
|
+
"token",
|
|
38
|
+
"whoami",
|
|
39
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Shared utilities for auth CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kstlib.auth.config import get_default_provider_name, list_configured_providers
|
|
10
|
+
from kstlib.auth.errors import AuthError, ConfigurationError
|
|
11
|
+
from kstlib.cli.common import exit_error
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from kstlib.auth.providers.base import AbstractAuthProvider
|
|
15
|
+
|
|
16
|
+
# Common CLI options
|
|
17
|
+
PROVIDER_ARGUMENT = typer.Argument(
|
|
18
|
+
None,
|
|
19
|
+
help="Provider name (uses default if not specified).",
|
|
20
|
+
show_default=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
QUIET_OPTION = typer.Option(
|
|
24
|
+
False,
|
|
25
|
+
"--quiet",
|
|
26
|
+
"-q",
|
|
27
|
+
help="Suppress verbose output.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
TIMEOUT_OPTION = typer.Option(
|
|
31
|
+
120,
|
|
32
|
+
"--timeout",
|
|
33
|
+
"-t",
|
|
34
|
+
help="Timeout in seconds for browser authentication.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_provider_name(provider: str | None) -> str:
|
|
39
|
+
"""Resolve provider name from argument or default.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
provider: Explicit provider name or None.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Resolved provider name.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
typer.Exit: If no provider specified and no default configured.
|
|
49
|
+
"""
|
|
50
|
+
if provider:
|
|
51
|
+
return provider
|
|
52
|
+
|
|
53
|
+
default = get_default_provider_name()
|
|
54
|
+
if default:
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
configured = list_configured_providers()
|
|
58
|
+
if not configured:
|
|
59
|
+
return exit_error(
|
|
60
|
+
"No auth providers configured.\nConfigure providers in kstlib.conf.yml under 'auth.providers'."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if len(configured) == 1:
|
|
64
|
+
return configured[0]
|
|
65
|
+
|
|
66
|
+
return exit_error(
|
|
67
|
+
f"Multiple providers configured: {', '.join(configured)}\n"
|
|
68
|
+
"Specify a provider name or set 'auth.default_provider' in config."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_provider(provider_name: str) -> AbstractAuthProvider:
|
|
73
|
+
"""Get a configured auth provider by name.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
provider_name: Name of the provider.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Configured provider instance.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
typer.Exit: If provider not found or misconfigured.
|
|
83
|
+
"""
|
|
84
|
+
from kstlib.auth.config import get_provider_config
|
|
85
|
+
|
|
86
|
+
provider_cfg = get_provider_config(provider_name)
|
|
87
|
+
if provider_cfg is None:
|
|
88
|
+
configured = list_configured_providers()
|
|
89
|
+
if configured:
|
|
90
|
+
exit_error(f"Provider '{provider_name}' not found.\nAvailable providers: {', '.join(configured)}")
|
|
91
|
+
else:
|
|
92
|
+
exit_error(f"Provider '{provider_name}' not found.\nNo providers configured in kstlib.conf.yml.")
|
|
93
|
+
|
|
94
|
+
# Determine provider type and instantiate
|
|
95
|
+
provider_type = provider_cfg.get("type", "oidc").lower()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if provider_type in ("oidc", "openid", "openidconnect"):
|
|
99
|
+
from kstlib.auth.providers.oidc import OIDCProvider
|
|
100
|
+
|
|
101
|
+
return OIDCProvider.from_config(provider_name)
|
|
102
|
+
|
|
103
|
+
if provider_type in ("oauth2", "oauth"):
|
|
104
|
+
from kstlib.auth.providers.oauth2 import OAuth2Provider
|
|
105
|
+
|
|
106
|
+
return OAuth2Provider.from_config(provider_name)
|
|
107
|
+
|
|
108
|
+
exit_error(f"Unknown provider type '{provider_type}' for '{provider_name}'.")
|
|
109
|
+
|
|
110
|
+
except ConfigurationError as e:
|
|
111
|
+
exit_error(f"Configuration error for '{provider_name}': {e}")
|
|
112
|
+
except AuthError as e:
|
|
113
|
+
exit_error(f"Auth error for '{provider_name}': {e}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"PROVIDER_ARGUMENT",
|
|
118
|
+
"QUIET_OPTION",
|
|
119
|
+
"TIMEOUT_OPTION",
|
|
120
|
+
"get_provider",
|
|
121
|
+
"resolve_provider_name",
|
|
122
|
+
]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Authenticate with an OAuth2/OIDC provider."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=too-many-branches,too-many-statements
|
|
4
|
+
# Justification: OAuth2 login flow with multiple user-facing modes (quiet/verbose,
|
|
5
|
+
# browser/no-browser/manual, PKCE/standard) and comprehensive error handling. Each
|
|
6
|
+
# branch handles a distinct case - decomposing would obscure the linear auth flow.
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import webbrowser
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
# Note: parse_qs not used - it converts + to space, breaking base64 codes
|
|
15
|
+
import typer
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.prompt import Prompt
|
|
18
|
+
|
|
19
|
+
from kstlib.auth.callback import CallbackServer
|
|
20
|
+
from kstlib.auth.config import get_callback_server_config
|
|
21
|
+
from kstlib.auth.errors import AuthError, CallbackServerError, TokenExchangeError
|
|
22
|
+
from kstlib.cli.common import CommandResult, CommandStatus, console, exit_error, render_result
|
|
23
|
+
|
|
24
|
+
from .common import PROVIDER_ARGUMENT, QUIET_OPTION, TIMEOUT_OPTION, get_provider, resolve_provider_name
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from kstlib.auth.providers.base import AbstractAuthProvider
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Defense in depth: limits for manual input
|
|
31
|
+
_MAX_INPUT_LENGTH = 8192 # Max URL/code input length (increased for long base64 codes)
|
|
32
|
+
_MAX_CODE_LENGTH = 2048 # Max authorization code length (some IdPs use long base64 codes)
|
|
33
|
+
_CODE_PATTERN = re.compile(r"^[a-zA-Z0-9._~+/=-]+$") # RFC 6749 safe chars + base64
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_code_from_input(user_input: str) -> tuple[str | None, str | None]:
|
|
37
|
+
"""Extract authorization code and state from user input.
|
|
38
|
+
|
|
39
|
+
Handles both full redirect URLs and raw code values.
|
|
40
|
+
Applies defense-in-depth validation on extracted values.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
user_input: URL with code parameter or raw code value.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Tuple of (code, state) where state may be None.
|
|
47
|
+
"""
|
|
48
|
+
user_input = user_input.strip()
|
|
49
|
+
|
|
50
|
+
# Defense: limit input length to prevent DoS
|
|
51
|
+
if len(user_input) > _MAX_INPUT_LENGTH:
|
|
52
|
+
return None, None
|
|
53
|
+
|
|
54
|
+
code: str | None = None
|
|
55
|
+
state: str | None = None
|
|
56
|
+
|
|
57
|
+
# Extract code and state via regex (preserves + and / in base64 codes)
|
|
58
|
+
# Note: parse_qs converts + to space, breaking base64-encoded codes
|
|
59
|
+
code_match = re.search(r"[?&]code=([^&\s]+)", user_input)
|
|
60
|
+
if code_match:
|
|
61
|
+
code = code_match.group(1)
|
|
62
|
+
state_match = re.search(r"[?&]state=([^&\s]+)", user_input)
|
|
63
|
+
state = state_match.group(1) if state_match else None
|
|
64
|
+
|
|
65
|
+
# Assume raw code value (no URL structure)
|
|
66
|
+
if not code and user_input and not user_input.startswith(("?", "&", "=")):
|
|
67
|
+
code = user_input
|
|
68
|
+
|
|
69
|
+
# Defense: validate code format and length
|
|
70
|
+
if code:
|
|
71
|
+
if len(code) > _MAX_CODE_LENGTH:
|
|
72
|
+
return None, None
|
|
73
|
+
if not _CODE_PATTERN.match(code):
|
|
74
|
+
return None, None
|
|
75
|
+
|
|
76
|
+
return code, state
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _login_manual(
|
|
80
|
+
auth_provider: AbstractAuthProvider,
|
|
81
|
+
provider_name: str,
|
|
82
|
+
quiet: bool,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Perform manual login without callback server.
|
|
85
|
+
|
|
86
|
+
Displays the authorization URL and prompts for the redirect URL or code.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
auth_provider: The authentication provider instance.
|
|
90
|
+
provider_name: Name of the provider for display.
|
|
91
|
+
quiet: Suppress verbose output.
|
|
92
|
+
"""
|
|
93
|
+
# Generate authorization URL (with PKCE if supported)
|
|
94
|
+
if hasattr(auth_provider, "get_authorization_url_with_pkce"):
|
|
95
|
+
auth_url, state, code_verifier = auth_provider.get_authorization_url_with_pkce()
|
|
96
|
+
else:
|
|
97
|
+
auth_url, state = auth_provider.get_authorization_url()
|
|
98
|
+
code_verifier = None
|
|
99
|
+
|
|
100
|
+
# Display instructions and URL
|
|
101
|
+
console.print(
|
|
102
|
+
Panel(
|
|
103
|
+
"[bold]Manual authentication mode[/]\n\n"
|
|
104
|
+
"1. Copy the URL below and open it in your browser\n"
|
|
105
|
+
"2. Complete the authentication\n"
|
|
106
|
+
"3. Copy the redirect URL from your browser (even if it shows an error)\n"
|
|
107
|
+
"4. Paste it below",
|
|
108
|
+
title="Manual Login",
|
|
109
|
+
style="cyan",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
console.print()
|
|
113
|
+
console.print("[bold]Authorization URL:[/]")
|
|
114
|
+
console.print(f"\n{auth_url}\n", soft_wrap=True, highlight=False)
|
|
115
|
+
|
|
116
|
+
# Prompt for redirect URL or code
|
|
117
|
+
console.print("[bold]After authentication, paste the redirect URL or code:[/]")
|
|
118
|
+
user_input = Prompt.ask("[dim](paste URL or code)[/]")
|
|
119
|
+
|
|
120
|
+
if not user_input:
|
|
121
|
+
exit_error("No input provided.")
|
|
122
|
+
|
|
123
|
+
# Extract code from input
|
|
124
|
+
code, returned_state = _extract_code_from_input(user_input)
|
|
125
|
+
|
|
126
|
+
if not code:
|
|
127
|
+
exit_error(
|
|
128
|
+
"Could not extract authorization code from input.\nExpected a URL with ?code=... or the raw code value."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Validate state if returned
|
|
132
|
+
if returned_state and returned_state != state:
|
|
133
|
+
exit_error("State mismatch - possible CSRF attack.")
|
|
134
|
+
|
|
135
|
+
# Exchange code for token
|
|
136
|
+
if not quiet:
|
|
137
|
+
console.print("[dim]Exchanging authorization code for token...[/]")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
token = auth_provider.exchange_code(
|
|
141
|
+
code=code,
|
|
142
|
+
state=state,
|
|
143
|
+
code_verifier=code_verifier,
|
|
144
|
+
)
|
|
145
|
+
except TokenExchangeError as e:
|
|
146
|
+
exit_error(f"Token exchange failed: {e}")
|
|
147
|
+
|
|
148
|
+
# Success
|
|
149
|
+
render_result(
|
|
150
|
+
CommandResult(
|
|
151
|
+
status=CommandStatus.OK,
|
|
152
|
+
message=f"Successfully authenticated with {provider_name}.",
|
|
153
|
+
payload={
|
|
154
|
+
"provider": provider_name,
|
|
155
|
+
"token_type": token.token_type.value if hasattr(token.token_type, "value") else str(token.token_type),
|
|
156
|
+
"expires_in": token.expires_in,
|
|
157
|
+
"scopes": token.scope,
|
|
158
|
+
}
|
|
159
|
+
if not quiet
|
|
160
|
+
else None,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _login_with_callback(
|
|
166
|
+
auth_provider: AbstractAuthProvider,
|
|
167
|
+
provider_name: str,
|
|
168
|
+
quiet: bool,
|
|
169
|
+
timeout: int,
|
|
170
|
+
no_browser: bool,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Perform login using local callback server.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
auth_provider: The authentication provider instance.
|
|
176
|
+
provider_name: Name of the provider for display.
|
|
177
|
+
quiet: Suppress verbose output.
|
|
178
|
+
timeout: Callback timeout in seconds.
|
|
179
|
+
no_browser: Print URL instead of opening browser.
|
|
180
|
+
"""
|
|
181
|
+
callback_cfg = get_callback_server_config()
|
|
182
|
+
|
|
183
|
+
with CallbackServer(
|
|
184
|
+
host=callback_cfg["host"],
|
|
185
|
+
port=callback_cfg["port"],
|
|
186
|
+
) as server:
|
|
187
|
+
# Generate authorization URL
|
|
188
|
+
if hasattr(auth_provider, "get_authorization_url_with_pkce"):
|
|
189
|
+
auth_url, state, code_verifier = auth_provider.get_authorization_url_with_pkce()
|
|
190
|
+
else:
|
|
191
|
+
auth_url, state = auth_provider.get_authorization_url()
|
|
192
|
+
code_verifier = None
|
|
193
|
+
|
|
194
|
+
if no_browser:
|
|
195
|
+
console.print(
|
|
196
|
+
Panel(
|
|
197
|
+
"Open this URL in your browser:",
|
|
198
|
+
title="Authorization URL",
|
|
199
|
+
style="cyan",
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
console.print(f"\n{auth_url}\n", soft_wrap=True, highlight=False)
|
|
203
|
+
else:
|
|
204
|
+
if not quiet:
|
|
205
|
+
console.print(f"[dim]Opening browser for {provider_name} authentication...[/]")
|
|
206
|
+
webbrowser.open(auth_url)
|
|
207
|
+
|
|
208
|
+
if not quiet:
|
|
209
|
+
console.print(f"[dim]Waiting for callback (timeout: {timeout}s)...[/]")
|
|
210
|
+
|
|
211
|
+
# Wait for callback
|
|
212
|
+
result = server.wait_for_callback(timeout=timeout)
|
|
213
|
+
|
|
214
|
+
if result.error:
|
|
215
|
+
exit_error(f"Authorization failed: {result.error_description or result.error}")
|
|
216
|
+
|
|
217
|
+
if result.code is None:
|
|
218
|
+
exit_error("No authorization code received.")
|
|
219
|
+
|
|
220
|
+
# Validate state
|
|
221
|
+
if result.state != state:
|
|
222
|
+
exit_error("State mismatch - possible CSRF attack.")
|
|
223
|
+
|
|
224
|
+
# Exchange code for token
|
|
225
|
+
if not quiet:
|
|
226
|
+
console.print("[dim]Exchanging authorization code for token...[/]")
|
|
227
|
+
|
|
228
|
+
token = auth_provider.exchange_code(
|
|
229
|
+
code=result.code,
|
|
230
|
+
state=state,
|
|
231
|
+
code_verifier=code_verifier,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Success
|
|
235
|
+
render_result(
|
|
236
|
+
CommandResult(
|
|
237
|
+
status=CommandStatus.OK,
|
|
238
|
+
message=f"Successfully authenticated with {provider_name}.",
|
|
239
|
+
payload={
|
|
240
|
+
"provider": provider_name,
|
|
241
|
+
"token_type": token.token_type.value
|
|
242
|
+
if hasattr(token.token_type, "value")
|
|
243
|
+
else str(token.token_type),
|
|
244
|
+
"expires_in": token.expires_in,
|
|
245
|
+
"scopes": token.scope,
|
|
246
|
+
}
|
|
247
|
+
if not quiet
|
|
248
|
+
else None,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def login( # noqa: PLR0913
|
|
254
|
+
provider: str | None = PROVIDER_ARGUMENT,
|
|
255
|
+
quiet: bool = QUIET_OPTION,
|
|
256
|
+
timeout: int = TIMEOUT_OPTION,
|
|
257
|
+
no_browser: bool = typer.Option(
|
|
258
|
+
False,
|
|
259
|
+
"--no-browser",
|
|
260
|
+
help="Print authorization URL instead of opening browser.",
|
|
261
|
+
),
|
|
262
|
+
manual: bool = typer.Option(
|
|
263
|
+
False,
|
|
264
|
+
"--manual",
|
|
265
|
+
"-m",
|
|
266
|
+
help="Manual mode: display URL and prompt for code (no callback server).",
|
|
267
|
+
),
|
|
268
|
+
force: bool = typer.Option(
|
|
269
|
+
False,
|
|
270
|
+
"--force",
|
|
271
|
+
"-f",
|
|
272
|
+
help="Force re-authentication even if already authenticated.",
|
|
273
|
+
),
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Authenticate with an OAuth2/OIDC provider.
|
|
276
|
+
|
|
277
|
+
Opens the system browser to complete the OAuth2 authorization flow.
|
|
278
|
+
Use --manual when the callback server cannot bind (e.g., port 443 in corporate environments).
|
|
279
|
+
"""
|
|
280
|
+
provider_name = resolve_provider_name(provider)
|
|
281
|
+
auth_provider = get_provider(provider_name)
|
|
282
|
+
|
|
283
|
+
# Check if already authenticated
|
|
284
|
+
if not force and auth_provider.is_authenticated:
|
|
285
|
+
if quiet:
|
|
286
|
+
console.print(f"[green]{provider_name}: already authenticated[/]")
|
|
287
|
+
else:
|
|
288
|
+
console.print(
|
|
289
|
+
Panel(
|
|
290
|
+
f"Already authenticated with [cyan]{provider_name}[/].\nUse [bold]--force[/] to re-authenticate.",
|
|
291
|
+
title="Auth Status",
|
|
292
|
+
style="green",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
if manual:
|
|
299
|
+
_login_manual(auth_provider, provider_name, quiet)
|
|
300
|
+
else:
|
|
301
|
+
_login_with_callback(auth_provider, provider_name, quiet, timeout, no_browser)
|
|
302
|
+
|
|
303
|
+
except CallbackServerError as e:
|
|
304
|
+
# Suggest manual mode if callback server fails
|
|
305
|
+
console.print(
|
|
306
|
+
Panel(
|
|
307
|
+
f"[red]{e}[/]\n\n"
|
|
308
|
+
"[yellow]Tip:[/] Use [bold]--manual[/] mode to authenticate without a callback server:\n"
|
|
309
|
+
f" kstlib auth login --manual {provider_name}",
|
|
310
|
+
title="Callback Server Error",
|
|
311
|
+
style="red",
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
raise typer.Exit(1) from None
|
|
315
|
+
except TokenExchangeError as e:
|
|
316
|
+
exit_error(f"Token exchange failed: {e}")
|
|
317
|
+
except AuthError as e:
|
|
318
|
+
exit_error(f"Authentication failed: {e}")
|
|
319
|
+
except TimeoutError:
|
|
320
|
+
exit_error(f"Authentication timed out after {timeout} seconds.")
|
|
321
|
+
except KeyboardInterrupt:
|
|
322
|
+
exit_error("Authentication cancelled by user.")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
__all__ = ["login"]
|