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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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,5 @@
1
+ """CLI command modules for kstlib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -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"]