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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Logout from an OAuth2/OIDC provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from kstlib.cli.common import CommandResult, CommandStatus, console, render_result
|
|
8
|
+
|
|
9
|
+
from .common import PROVIDER_ARGUMENT, QUIET_OPTION, get_provider, resolve_provider_name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def logout(
|
|
13
|
+
provider: str | None = PROVIDER_ARGUMENT,
|
|
14
|
+
quiet: bool = QUIET_OPTION,
|
|
15
|
+
revoke: bool = typer.Option(
|
|
16
|
+
True,
|
|
17
|
+
"--revoke/--no-revoke",
|
|
18
|
+
help="Attempt to revoke token at the authorization server.",
|
|
19
|
+
),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Logout from an OAuth2/OIDC provider.
|
|
22
|
+
|
|
23
|
+
Clears the stored token and optionally revokes it at the server.
|
|
24
|
+
"""
|
|
25
|
+
provider_name = resolve_provider_name(provider)
|
|
26
|
+
auth_provider = get_provider(provider_name)
|
|
27
|
+
|
|
28
|
+
# Check if authenticated
|
|
29
|
+
token = auth_provider.get_token(auto_refresh=False)
|
|
30
|
+
if token is None:
|
|
31
|
+
if quiet:
|
|
32
|
+
console.print(f"[yellow]{provider_name}: not authenticated[/]")
|
|
33
|
+
else:
|
|
34
|
+
render_result(
|
|
35
|
+
CommandResult(
|
|
36
|
+
status=CommandStatus.WARNING,
|
|
37
|
+
message=f"Not authenticated with {provider_name}.",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Attempt revocation if requested
|
|
43
|
+
revoked = False
|
|
44
|
+
if revoke:
|
|
45
|
+
if not quiet:
|
|
46
|
+
console.print(f"[dim]Revoking token for {provider_name}...[/]")
|
|
47
|
+
try:
|
|
48
|
+
revoked = auth_provider.revoke(token)
|
|
49
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
50
|
+
# Best-effort revocation
|
|
51
|
+
if not quiet:
|
|
52
|
+
console.print("[dim]Token revocation not supported or failed.[/]")
|
|
53
|
+
|
|
54
|
+
# Clear token from storage
|
|
55
|
+
auth_provider.clear_token()
|
|
56
|
+
|
|
57
|
+
# Success message
|
|
58
|
+
if revoked:
|
|
59
|
+
message = f"Logged out from {provider_name} (token revoked)."
|
|
60
|
+
else:
|
|
61
|
+
message = f"Logged out from {provider_name} (token cleared locally)."
|
|
62
|
+
|
|
63
|
+
if quiet:
|
|
64
|
+
console.print(f"[green]{message}[/]")
|
|
65
|
+
else:
|
|
66
|
+
render_result(
|
|
67
|
+
CommandResult(
|
|
68
|
+
status=CommandStatus.OK,
|
|
69
|
+
message=message,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["logout"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""List configured auth providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from kstlib.auth.config import (
|
|
9
|
+
get_default_provider_name,
|
|
10
|
+
get_provider_config,
|
|
11
|
+
list_configured_providers,
|
|
12
|
+
)
|
|
13
|
+
from kstlib.cli.common import console
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def providers(
|
|
17
|
+
verbose: bool = typer.Option(
|
|
18
|
+
False,
|
|
19
|
+
"--verbose",
|
|
20
|
+
"-v",
|
|
21
|
+
help="Show detailed provider configuration.",
|
|
22
|
+
),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List configured authentication providers."""
|
|
25
|
+
configured = list_configured_providers()
|
|
26
|
+
default = get_default_provider_name()
|
|
27
|
+
|
|
28
|
+
if not configured:
|
|
29
|
+
console.print("[yellow]No auth providers configured.[/]")
|
|
30
|
+
console.print("Configure providers in kstlib.conf.yml under 'auth.providers'.")
|
|
31
|
+
raise typer.Exit(0)
|
|
32
|
+
|
|
33
|
+
table = Table(title="Configured Auth Providers")
|
|
34
|
+
table.add_column("Provider", style="cyan")
|
|
35
|
+
table.add_column("Type", style="green")
|
|
36
|
+
if verbose:
|
|
37
|
+
table.add_column("Issuer / Endpoints", style="dim")
|
|
38
|
+
table.add_column("Default", style="yellow", justify="center")
|
|
39
|
+
|
|
40
|
+
for name in configured:
|
|
41
|
+
cfg = get_provider_config(name) or {}
|
|
42
|
+
provider_type = cfg.get("type", "oidc").upper()
|
|
43
|
+
is_default = "[bold]✓[/]" if name == default else ""
|
|
44
|
+
|
|
45
|
+
if verbose:
|
|
46
|
+
# Show issuer or authorization endpoint
|
|
47
|
+
issuer = cfg.get("issuer", "")
|
|
48
|
+
auth_endpoint = cfg.get("authorization_endpoint", cfg.get("authorize_url", ""))
|
|
49
|
+
endpoint_info = issuer or auth_endpoint or "[dim]not configured[/]"
|
|
50
|
+
table.add_row(name, provider_type, endpoint_info, is_default)
|
|
51
|
+
else:
|
|
52
|
+
table.add_row(name, provider_type, is_default)
|
|
53
|
+
|
|
54
|
+
console.print(table)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["providers"]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Show authentication status for a provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from kstlib.auth.config import get_status_config
|
|
13
|
+
from kstlib.cli.common import console
|
|
14
|
+
|
|
15
|
+
from .common import PROVIDER_ARGUMENT, QUIET_OPTION, get_provider, resolve_provider_name
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from kstlib.auth.models import Token
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class _DisplayContext:
|
|
23
|
+
"""Display context for status output."""
|
|
24
|
+
|
|
25
|
+
provider_name: str
|
|
26
|
+
status_text: str
|
|
27
|
+
status_style: str
|
|
28
|
+
threshold: int
|
|
29
|
+
refresh_threshold: int
|
|
30
|
+
use_local_tz: bool
|
|
31
|
+
is_expired: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_duration(seconds: int) -> str:
|
|
35
|
+
"""Format duration in human-readable form.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
seconds: Duration in seconds (can be negative for past).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Formatted duration string.
|
|
42
|
+
"""
|
|
43
|
+
abs_seconds = abs(seconds)
|
|
44
|
+
if abs_seconds > 3600:
|
|
45
|
+
return f"{abs_seconds // 3600}h {(abs_seconds % 3600) // 60}m"
|
|
46
|
+
if abs_seconds > 60:
|
|
47
|
+
return f"{abs_seconds // 60}m {abs_seconds % 60}s"
|
|
48
|
+
return f"{abs_seconds}s"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_datetime(dt: datetime, *, use_local: bool) -> str:
|
|
52
|
+
"""Format datetime for display.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
dt: Datetime to format (should be timezone-aware).
|
|
56
|
+
use_local: If True, convert to local timezone.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Formatted datetime string.
|
|
60
|
+
"""
|
|
61
|
+
if use_local:
|
|
62
|
+
local_dt = dt.astimezone()
|
|
63
|
+
return local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
64
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_refresh_token_expiry(token: Token) -> datetime | None:
|
|
68
|
+
"""Extract refresh token expiry from token metadata.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
token: Token object with potential refresh token info.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Refresh token expiry datetime, or None if unknown.
|
|
75
|
+
"""
|
|
76
|
+
metadata = token.metadata
|
|
77
|
+
|
|
78
|
+
# Check for refresh_expires_at (absolute timestamp)
|
|
79
|
+
if metadata.get("refresh_expires_at"):
|
|
80
|
+
try:
|
|
81
|
+
return datetime.fromisoformat(str(metadata["refresh_expires_at"]))
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# Check refresh_expires_in from token response (relative)
|
|
86
|
+
if metadata.get("refresh_expires_in"):
|
|
87
|
+
try:
|
|
88
|
+
return token.issued_at + timedelta(seconds=int(metadata["refresh_expires_in"]))
|
|
89
|
+
except (ValueError, TypeError):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _determine_status(token: Token, threshold: int) -> tuple[str, str, bool]:
|
|
96
|
+
"""Determine token status text and style.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
token: Token to check.
|
|
100
|
+
threshold: Expiring soon threshold in seconds.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (status_text, status_style, is_expired).
|
|
104
|
+
"""
|
|
105
|
+
expires_in = token.expires_in
|
|
106
|
+
is_expired = token.is_expired
|
|
107
|
+
|
|
108
|
+
if is_expired:
|
|
109
|
+
return "[red]Expired[/]", "red", True
|
|
110
|
+
if expires_in is not None and expires_in <= threshold:
|
|
111
|
+
return "[yellow]Expiring soon[/]", "yellow", False
|
|
112
|
+
return "[green]Valid[/]", "green", False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_access_token_rows(table: Table, token: Token, ctx: _DisplayContext) -> None:
|
|
116
|
+
"""Add access token rows to the status table.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
table: Rich table to add rows to.
|
|
120
|
+
token: Token object.
|
|
121
|
+
ctx: Display context.
|
|
122
|
+
"""
|
|
123
|
+
table.add_row("Provider", f"[cyan]{ctx.provider_name}[/]")
|
|
124
|
+
table.add_row("Status", ctx.status_text)
|
|
125
|
+
table.add_row(
|
|
126
|
+
"Token Type",
|
|
127
|
+
token.token_type.value if hasattr(token.token_type, "value") else str(token.token_type),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if token.issued_at:
|
|
131
|
+
table.add_row("Issued At", _format_datetime(token.issued_at, use_local=ctx.use_local_tz))
|
|
132
|
+
|
|
133
|
+
if token.expires_at:
|
|
134
|
+
table.add_row("Expires At", _format_datetime(token.expires_at, use_local=ctx.use_local_tz))
|
|
135
|
+
|
|
136
|
+
expires_in = token.expires_in
|
|
137
|
+
if expires_in is not None:
|
|
138
|
+
if ctx.is_expired:
|
|
139
|
+
table.add_row("Expired Since", f"[red]{_format_duration(expires_in)}[/]")
|
|
140
|
+
else:
|
|
141
|
+
table.add_row("Expires In", _format_duration(expires_in))
|
|
142
|
+
|
|
143
|
+
if token.scope:
|
|
144
|
+
table.add_row("Scopes", " ".join(token.scope))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_refresh_token_panel(token: Token, ctx: _DisplayContext) -> Panel | None:
|
|
148
|
+
"""Build a separate panel for refresh token status.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
token: Token object.
|
|
152
|
+
ctx: Display context.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Panel for refresh token, or None if no refresh token.
|
|
156
|
+
"""
|
|
157
|
+
if not token.is_refreshable:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
table = Table(show_header=False, box=None)
|
|
161
|
+
table.add_column("Field", style="dim")
|
|
162
|
+
table.add_column("Value")
|
|
163
|
+
|
|
164
|
+
refresh_expiry = _get_refresh_token_expiry(token)
|
|
165
|
+
|
|
166
|
+
if not refresh_expiry:
|
|
167
|
+
table.add_row("Status", "[green]Available[/]")
|
|
168
|
+
table.add_row("Expires At", "[dim]Unknown[/]")
|
|
169
|
+
return Panel(table, title="Refresh Token", style="green")
|
|
170
|
+
|
|
171
|
+
now = datetime.now(timezone.utc)
|
|
172
|
+
refresh_expires_in = int((refresh_expiry - now).total_seconds())
|
|
173
|
+
|
|
174
|
+
if refresh_expires_in <= 0:
|
|
175
|
+
status_text = "[red]Expired[/]"
|
|
176
|
+
panel_style = "red"
|
|
177
|
+
elif refresh_expires_in <= ctx.refresh_threshold:
|
|
178
|
+
status_text = "[yellow]Expiring soon[/]"
|
|
179
|
+
panel_style = "yellow"
|
|
180
|
+
else:
|
|
181
|
+
status_text = "[green]Valid[/]"
|
|
182
|
+
panel_style = "green"
|
|
183
|
+
|
|
184
|
+
table.add_row("Status", status_text)
|
|
185
|
+
table.add_row("Expires At", _format_datetime(refresh_expiry, use_local=ctx.use_local_tz))
|
|
186
|
+
|
|
187
|
+
if refresh_expires_in <= 0:
|
|
188
|
+
table.add_row("Expired Since", f"[red]{_format_duration(refresh_expires_in)}[/]")
|
|
189
|
+
else:
|
|
190
|
+
table.add_row("Expires In", _format_duration(refresh_expires_in))
|
|
191
|
+
|
|
192
|
+
return Panel(table, title="Refresh Token", style=panel_style)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _show_not_authenticated(provider_name: str, quiet: bool) -> None:
|
|
196
|
+
"""Display not authenticated message.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
provider_name: Provider name.
|
|
200
|
+
quiet: Whether to use quiet mode.
|
|
201
|
+
"""
|
|
202
|
+
if quiet:
|
|
203
|
+
console.print(f"[yellow]{provider_name}: not authenticated[/]")
|
|
204
|
+
else:
|
|
205
|
+
console.print(
|
|
206
|
+
Panel(
|
|
207
|
+
f"Not authenticated with [cyan]{provider_name}[/].\n"
|
|
208
|
+
f"Run [bold]kstlib auth login {provider_name}[/] to authenticate.",
|
|
209
|
+
title="Auth Status",
|
|
210
|
+
style="yellow",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _show_quiet_status(provider_name: str, token: Token, status_text: str, is_expired: bool) -> None:
|
|
216
|
+
"""Display quiet mode status.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
provider_name: Provider name.
|
|
220
|
+
token: Token object.
|
|
221
|
+
status_text: Formatted status text.
|
|
222
|
+
is_expired: Whether token is expired.
|
|
223
|
+
"""
|
|
224
|
+
expires_in = token.expires_in
|
|
225
|
+
if expires_in is not None:
|
|
226
|
+
duration = _format_duration(expires_in)
|
|
227
|
+
if is_expired:
|
|
228
|
+
console.print(f"{provider_name}: {status_text} (expired {duration} ago)")
|
|
229
|
+
else:
|
|
230
|
+
console.print(f"{provider_name}: {status_text} (expires in {duration})")
|
|
231
|
+
else:
|
|
232
|
+
console.print(f"{provider_name}: {status_text}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _show_verbose_status(token: Token, ctx: _DisplayContext) -> None:
|
|
236
|
+
"""Display verbose mode status.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
token: Token object.
|
|
240
|
+
ctx: Display context.
|
|
241
|
+
"""
|
|
242
|
+
# Access token panel
|
|
243
|
+
table = Table(show_header=False, box=None)
|
|
244
|
+
table.add_column("Field", style="dim")
|
|
245
|
+
table.add_column("Value")
|
|
246
|
+
_build_access_token_rows(table, token, ctx)
|
|
247
|
+
console.print(Panel(table, title="Access Token", style=ctx.status_style))
|
|
248
|
+
|
|
249
|
+
# Refresh token panel (separate, with its own status color)
|
|
250
|
+
refresh_panel = _build_refresh_token_panel(token, ctx)
|
|
251
|
+
if refresh_panel:
|
|
252
|
+
console.print(refresh_panel)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def status(
|
|
256
|
+
provider: str | None = PROVIDER_ARGUMENT,
|
|
257
|
+
quiet: bool = QUIET_OPTION,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Show authentication status for a provider."""
|
|
260
|
+
provider_name = resolve_provider_name(provider)
|
|
261
|
+
auth_provider = get_provider(provider_name)
|
|
262
|
+
token = auth_provider.get_token(auto_refresh=False)
|
|
263
|
+
|
|
264
|
+
if token is None:
|
|
265
|
+
_show_not_authenticated(provider_name, quiet)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
status_cfg = get_status_config()
|
|
269
|
+
threshold = status_cfg["expiring_soon_threshold"]
|
|
270
|
+
refresh_threshold = status_cfg["refresh_expiring_soon_threshold"]
|
|
271
|
+
use_local_tz = status_cfg["display_timezone"] == "local"
|
|
272
|
+
|
|
273
|
+
status_text, status_style, is_expired = _determine_status(token, threshold)
|
|
274
|
+
|
|
275
|
+
if quiet:
|
|
276
|
+
_show_quiet_status(provider_name, token, status_text, is_expired)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
ctx = _DisplayContext(
|
|
280
|
+
provider_name=provider_name,
|
|
281
|
+
status_text=status_text,
|
|
282
|
+
status_style=status_style,
|
|
283
|
+
threshold=threshold,
|
|
284
|
+
refresh_threshold=refresh_threshold,
|
|
285
|
+
use_local_tz=use_local_tz,
|
|
286
|
+
is_expired=is_expired,
|
|
287
|
+
)
|
|
288
|
+
_show_verbose_status(token, ctx)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
__all__ = ["status"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Show or copy the access token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from kstlib.cli.common import console, exit_error
|
|
12
|
+
from kstlib.utils.serialization import to_json
|
|
13
|
+
|
|
14
|
+
from .common import PROVIDER_ARGUMENT, get_provider, resolve_provider_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _decode_jwt(token_str: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
|
18
|
+
"""Decode a JWT and return (header, payload) dicts.
|
|
19
|
+
|
|
20
|
+
Returns None if token is not a valid JWT format.
|
|
21
|
+
"""
|
|
22
|
+
parts = token_str.split(".")
|
|
23
|
+
if len(parts) != 3:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# Add padding if needed (base64url)
|
|
28
|
+
def decode_part(part: str) -> dict[str, Any]:
|
|
29
|
+
# Add padding
|
|
30
|
+
padding = 4 - len(part) % 4
|
|
31
|
+
if padding != 4:
|
|
32
|
+
part += "=" * padding
|
|
33
|
+
decoded = base64.urlsafe_b64decode(part)
|
|
34
|
+
return json.loads(decoded) # type: ignore[no-any-return]
|
|
35
|
+
|
|
36
|
+
header = decode_part(parts[0])
|
|
37
|
+
payload = decode_part(parts[1])
|
|
38
|
+
return header, payload
|
|
39
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_decoded(
|
|
44
|
+
header: dict[str, Any],
|
|
45
|
+
payload: dict[str, Any],
|
|
46
|
+
*,
|
|
47
|
+
as_json: bool = False,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Format decoded JWT for display."""
|
|
50
|
+
if as_json:
|
|
51
|
+
return to_json({"header": header, "payload": payload})
|
|
52
|
+
|
|
53
|
+
# YAML-like format (more readable)
|
|
54
|
+
lines = ["[bold cyan]--- JWT Header ---[/]"]
|
|
55
|
+
for key, value in header.items():
|
|
56
|
+
lines.append(f"[dim]{key}:[/] {value}")
|
|
57
|
+
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("[bold cyan]--- JWT Payload ---[/]")
|
|
60
|
+
for key, value in payload.items():
|
|
61
|
+
# Special formatting for timestamps
|
|
62
|
+
if key in ("exp", "iat", "auth_time", "nbf") and isinstance(value, int):
|
|
63
|
+
from datetime import datetime, timezone
|
|
64
|
+
|
|
65
|
+
dt = datetime.fromtimestamp(value, tz=timezone.utc)
|
|
66
|
+
lines.append(f"[dim]{key}:[/] {value} [dim]({dt.isoformat()})[/]")
|
|
67
|
+
elif isinstance(value, list):
|
|
68
|
+
lines.append(f"[dim]{key}:[/] {', '.join(str(v) for v in value)}")
|
|
69
|
+
else:
|
|
70
|
+
lines.append(f"[dim]{key}:[/] {value}")
|
|
71
|
+
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def token(
|
|
76
|
+
provider: str | None = PROVIDER_ARGUMENT,
|
|
77
|
+
copy: bool = typer.Option(
|
|
78
|
+
False,
|
|
79
|
+
"--copy",
|
|
80
|
+
"-c",
|
|
81
|
+
help="Copy token to clipboard instead of printing.",
|
|
82
|
+
),
|
|
83
|
+
refresh: bool = typer.Option(
|
|
84
|
+
False,
|
|
85
|
+
"--refresh",
|
|
86
|
+
"-r",
|
|
87
|
+
help="Force refresh token before displaying.",
|
|
88
|
+
),
|
|
89
|
+
show_refresh: bool = typer.Option(
|
|
90
|
+
False,
|
|
91
|
+
"--show-refresh",
|
|
92
|
+
help="Show the refresh token instead of access token.",
|
|
93
|
+
),
|
|
94
|
+
decode: bool = typer.Option(
|
|
95
|
+
False,
|
|
96
|
+
"--decode",
|
|
97
|
+
"-d",
|
|
98
|
+
help="Decode JWT and show header + payload (human-readable).",
|
|
99
|
+
),
|
|
100
|
+
as_json: bool = typer.Option(
|
|
101
|
+
False,
|
|
102
|
+
"--json",
|
|
103
|
+
"-j",
|
|
104
|
+
help="Output decoded JWT as JSON (requires --decode).",
|
|
105
|
+
),
|
|
106
|
+
header: bool = typer.Option(
|
|
107
|
+
False,
|
|
108
|
+
"--header",
|
|
109
|
+
"-H",
|
|
110
|
+
help="Output as Authorization header value (access token only).",
|
|
111
|
+
),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Show or copy the current access token.
|
|
114
|
+
|
|
115
|
+
By default, prints the raw access token. Use --header to get the
|
|
116
|
+
full Authorization header value (e.g., 'Bearer <token>').
|
|
117
|
+
|
|
118
|
+
Use --show-refresh to display the refresh token instead.
|
|
119
|
+
|
|
120
|
+
Use --decode to view the JWT header and payload in a readable format.
|
|
121
|
+
"""
|
|
122
|
+
provider_name = resolve_provider_name(provider)
|
|
123
|
+
auth_provider = get_provider(provider_name)
|
|
124
|
+
|
|
125
|
+
# Force refresh if requested
|
|
126
|
+
if refresh:
|
|
127
|
+
current_token = auth_provider.get_token(auto_refresh=False)
|
|
128
|
+
if current_token is None:
|
|
129
|
+
exit_error(f"Not authenticated with {provider_name}.\nRun 'kstlib auth login {provider_name}' first.")
|
|
130
|
+
if not current_token.is_refreshable:
|
|
131
|
+
exit_error("Token cannot be refreshed (no refresh_token).")
|
|
132
|
+
try:
|
|
133
|
+
current_token = auth_provider.refresh(current_token)
|
|
134
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
135
|
+
exit_error(f"Token refresh failed: {e}")
|
|
136
|
+
else:
|
|
137
|
+
current_token = auth_provider.get_token(auto_refresh=True)
|
|
138
|
+
|
|
139
|
+
if current_token is None:
|
|
140
|
+
exit_error(f"Not authenticated with {provider_name}.\nRun 'kstlib auth login {provider_name}' first.")
|
|
141
|
+
|
|
142
|
+
# Validate incompatible options
|
|
143
|
+
if as_json and not decode:
|
|
144
|
+
exit_error("--json requires --decode.")
|
|
145
|
+
if decode and header:
|
|
146
|
+
exit_error("--decode and --header cannot be used together.")
|
|
147
|
+
if decode and copy:
|
|
148
|
+
exit_error("--decode and --copy cannot be used together.")
|
|
149
|
+
|
|
150
|
+
# Handle --show-refresh
|
|
151
|
+
if show_refresh:
|
|
152
|
+
if header:
|
|
153
|
+
exit_error("--header cannot be used with --show-refresh (refresh tokens are not used in headers).")
|
|
154
|
+
if not current_token.refresh_token:
|
|
155
|
+
exit_error("No refresh token available for this session.")
|
|
156
|
+
raw_token = current_token.refresh_token
|
|
157
|
+
else:
|
|
158
|
+
raw_token = current_token.access_token
|
|
159
|
+
|
|
160
|
+
# Handle --decode
|
|
161
|
+
if decode:
|
|
162
|
+
decoded = _decode_jwt(raw_token)
|
|
163
|
+
if decoded is None:
|
|
164
|
+
exit_error("Token is not a valid JWT format.")
|
|
165
|
+
jwt_header, jwt_payload = decoded
|
|
166
|
+
output = _format_decoded(jwt_header, jwt_payload, as_json=as_json)
|
|
167
|
+
if as_json:
|
|
168
|
+
print(output)
|
|
169
|
+
else:
|
|
170
|
+
console.print(output)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Format output for raw token
|
|
174
|
+
if header:
|
|
175
|
+
token_type = (
|
|
176
|
+
current_token.token_type.value
|
|
177
|
+
if hasattr(current_token.token_type, "value")
|
|
178
|
+
else str(current_token.token_type)
|
|
179
|
+
)
|
|
180
|
+
output = f"{token_type} {raw_token}"
|
|
181
|
+
else:
|
|
182
|
+
output = raw_token
|
|
183
|
+
|
|
184
|
+
if copy:
|
|
185
|
+
try:
|
|
186
|
+
import pyperclip # type: ignore[import-untyped]
|
|
187
|
+
|
|
188
|
+
pyperclip.copy(output)
|
|
189
|
+
console.print(f"[green]Token copied to clipboard ({provider_name}).[/]")
|
|
190
|
+
except ImportError:
|
|
191
|
+
exit_error("Clipboard support requires pyperclip.\nInstall with: pip install pyperclip")
|
|
192
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
193
|
+
exit_error(f"Failed to copy to clipboard: {e}")
|
|
194
|
+
else:
|
|
195
|
+
# Print raw token (no Rich formatting for easy piping)
|
|
196
|
+
print(output)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = ["token"]
|