authsome 0.1.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.
- authsome/__init__.py +82 -0
- authsome/bundled_providers/__init__.py +1 -0
- authsome/bundled_providers/github.json +30 -0
- authsome/bundled_providers/google.json +30 -0
- authsome/bundled_providers/linear.json +30 -0
- authsome/bundled_providers/okta.json +26 -0
- authsome/bundled_providers/openai.json +18 -0
- authsome/cli.py +453 -0
- authsome/client.py +960 -0
- authsome/crypto/__init__.py +13 -0
- authsome/crypto/base.py +45 -0
- authsome/crypto/keyring_crypto.py +125 -0
- authsome/crypto/local_file_crypto.py +127 -0
- authsome/errors.py +119 -0
- authsome/flows/__init__.py +16 -0
- authsome/flows/api_key.py +142 -0
- authsome/flows/base.py +46 -0
- authsome/flows/dcr_pkce.py +403 -0
- authsome/flows/device_code.py +295 -0
- authsome/flows/pkce.py +294 -0
- authsome/models/__init__.py +39 -0
- authsome/models/config.py +39 -0
- authsome/models/connection.py +106 -0
- authsome/models/enums.py +38 -0
- authsome/models/profile.py +23 -0
- authsome/models/provider.py +111 -0
- authsome/providers/__init__.py +5 -0
- authsome/providers/registry.py +239 -0
- authsome/py.typed +0 -0
- authsome/store/__init__.py +6 -0
- authsome/store/base.py +76 -0
- authsome/store/sqlite_store.py +147 -0
- authsome/utils.py +74 -0
- authsome-0.1.0.dist-info/METADATA +261 -0
- authsome-0.1.0.dist-info/RECORD +38 -0
- authsome-0.1.0.dist-info/WHEEL +4 -0
- authsome-0.1.0.dist-info/entry_points.txt +2 -0
- authsome-0.1.0.dist-info/licenses/LICENSE +21 -0
authsome/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authsome — A portable local authentication library for AI agents and developer tools.
|
|
3
|
+
|
|
4
|
+
Provides credential management for third-party services with support for:
|
|
5
|
+
- OAuth2 (PKCE, Device Code, DCR + PKCE)
|
|
6
|
+
- API key management (prompt, env import)
|
|
7
|
+
- Encrypted local storage (OS keyring or local file)
|
|
8
|
+
- Cross-language compatible credential format
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from authsome import AuthClient
|
|
12
|
+
|
|
13
|
+
client = AuthClient()
|
|
14
|
+
client.init()
|
|
15
|
+
|
|
16
|
+
# Login to a provider
|
|
17
|
+
client.login("openai")
|
|
18
|
+
|
|
19
|
+
# Get auth headers for API calls
|
|
20
|
+
headers = client.get_auth_headers("openai")
|
|
21
|
+
|
|
22
|
+
# Export credentials
|
|
23
|
+
env_vars = client.export("openai", format=ExportFormat.SHELL)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from authsome.client import AuthClient
|
|
27
|
+
from authsome.errors import (
|
|
28
|
+
AuthenticationFailedError,
|
|
29
|
+
AuthsomeError,
|
|
30
|
+
ConnectionNotFoundError,
|
|
31
|
+
CredentialMissingError,
|
|
32
|
+
DiscoveryError,
|
|
33
|
+
EncryptionUnavailableError,
|
|
34
|
+
InvalidProviderSchemaError,
|
|
35
|
+
ProfileNotFoundError,
|
|
36
|
+
ProviderNotFoundError,
|
|
37
|
+
RefreshFailedError,
|
|
38
|
+
StoreUnavailableError,
|
|
39
|
+
TokenExpiredError,
|
|
40
|
+
UnsupportedAuthTypeError,
|
|
41
|
+
UnsupportedFlowError,
|
|
42
|
+
)
|
|
43
|
+
from authsome.models.enums import AuthType, ConnectionStatus, ExportFormat, FlowType
|
|
44
|
+
from authsome.models.provider import ProviderDefinition
|
|
45
|
+
from authsome.models.connection import ConnectionRecord, EncryptedField
|
|
46
|
+
from authsome.crypto.base import CryptoBackend
|
|
47
|
+
from authsome.crypto.keyring_crypto import KeyringCryptoBackend
|
|
48
|
+
from authsome.crypto.local_file_crypto import LocalFileCryptoBackend
|
|
49
|
+
|
|
50
|
+
__version__ = "0.1.0"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
# Core
|
|
54
|
+
"AuthClient",
|
|
55
|
+
# Models
|
|
56
|
+
"AuthType",
|
|
57
|
+
"ConnectionStatus",
|
|
58
|
+
"ExportFormat",
|
|
59
|
+
"FlowType",
|
|
60
|
+
"ProviderDefinition",
|
|
61
|
+
"ConnectionRecord",
|
|
62
|
+
"EncryptedField",
|
|
63
|
+
# Crypto backends
|
|
64
|
+
"CryptoBackend",
|
|
65
|
+
"KeyringCryptoBackend",
|
|
66
|
+
"LocalFileCryptoBackend",
|
|
67
|
+
# Errors
|
|
68
|
+
"AuthsomeError",
|
|
69
|
+
"AuthenticationFailedError",
|
|
70
|
+
"ConnectionNotFoundError",
|
|
71
|
+
"CredentialMissingError",
|
|
72
|
+
"DiscoveryError",
|
|
73
|
+
"EncryptionUnavailableError",
|
|
74
|
+
"InvalidProviderSchemaError",
|
|
75
|
+
"ProfileNotFoundError",
|
|
76
|
+
"ProviderNotFoundError",
|
|
77
|
+
"RefreshFailedError",
|
|
78
|
+
"StoreUnavailableError",
|
|
79
|
+
"TokenExpiredError",
|
|
80
|
+
"UnsupportedAuthTypeError",
|
|
81
|
+
"UnsupportedFlowError",
|
|
82
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled provider definitions package for authsome."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"name": "github",
|
|
4
|
+
"display_name": "GitHub",
|
|
5
|
+
"auth_type": "oauth2",
|
|
6
|
+
"flow": "pkce",
|
|
7
|
+
"oauth": {
|
|
8
|
+
"authorization_url": "https://github.com/login/oauth/authorize",
|
|
9
|
+
"token_url": "https://github.com/login/oauth/access_token",
|
|
10
|
+
"revocation_url": null,
|
|
11
|
+
"device_authorization_url": "https://github.com/login/device/code",
|
|
12
|
+
"scopes": [
|
|
13
|
+
"repo",
|
|
14
|
+
"read:user"
|
|
15
|
+
],
|
|
16
|
+
"pkce": true,
|
|
17
|
+
"supports_device_flow": true,
|
|
18
|
+
"supports_dcr": false
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"client_id": "env:GITHUB_CLIENT_ID",
|
|
22
|
+
"client_secret": "env:GITHUB_CLIENT_SECRET"
|
|
23
|
+
},
|
|
24
|
+
"export": {
|
|
25
|
+
"env": {
|
|
26
|
+
"access_token": "GITHUB_ACCESS_TOKEN",
|
|
27
|
+
"refresh_token": "GITHUB_REFRESH_TOKEN"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"name": "google",
|
|
4
|
+
"display_name": "Google",
|
|
5
|
+
"auth_type": "oauth2",
|
|
6
|
+
"flow": "pkce",
|
|
7
|
+
"oauth": {
|
|
8
|
+
"authorization_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
9
|
+
"token_url": "https://oauth2.googleapis.com/token",
|
|
10
|
+
"revocation_url": "https://oauth2.googleapis.com/revoke",
|
|
11
|
+
"device_authorization_url": "https://oauth2.googleapis.com/device/code",
|
|
12
|
+
"scopes": [
|
|
13
|
+
"openid",
|
|
14
|
+
"profile"
|
|
15
|
+
],
|
|
16
|
+
"pkce": true,
|
|
17
|
+
"supports_device_flow": true,
|
|
18
|
+
"supports_dcr": false
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"client_id": "env:GOOGLE_CLIENT_ID",
|
|
22
|
+
"client_secret": "env:GOOGLE_CLIENT_SECRET"
|
|
23
|
+
},
|
|
24
|
+
"export": {
|
|
25
|
+
"env": {
|
|
26
|
+
"access_token": "GOOGLE_ACCESS_TOKEN",
|
|
27
|
+
"refresh_token": "GOOGLE_REFRESH_TOKEN"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"name": "linear",
|
|
4
|
+
"display_name": "Linear",
|
|
5
|
+
"auth_type": "oauth2",
|
|
6
|
+
"flow": "dcr_pkce",
|
|
7
|
+
"oauth": {
|
|
8
|
+
"authorization_url": "https://mcp.linear.app/authorize",
|
|
9
|
+
"token_url": "https://mcp.linear.app/token",
|
|
10
|
+
"revocation_url": "https://mcp.linear.app/token",
|
|
11
|
+
"device_authorization_url": null,
|
|
12
|
+
"scopes": [
|
|
13
|
+
"read",
|
|
14
|
+
"write"
|
|
15
|
+
],
|
|
16
|
+
"pkce": true,
|
|
17
|
+
"supports_device_flow": false,
|
|
18
|
+
"supports_dcr": true
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"client_id": "env:LINEAR_CLIENT_ID",
|
|
22
|
+
"client_secret": "env:LINEAR_CLIENT_SECRET"
|
|
23
|
+
},
|
|
24
|
+
"export": {
|
|
25
|
+
"env": {
|
|
26
|
+
"access_token": "LINEAR_ACCESS_TOKEN",
|
|
27
|
+
"refresh_token": "LINEAR_REFRESH_TOKEN"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"name": "okta",
|
|
4
|
+
"display_name": "Okta",
|
|
5
|
+
"auth_type": "oauth2",
|
|
6
|
+
"flow": "pkce",
|
|
7
|
+
"oauth": {
|
|
8
|
+
"authorization_url": "https://integrator-7955628.okta.com/oauth2/default/v1/authorize",
|
|
9
|
+
"token_url": "https://integrator-7955628.okta.com/oauth2/default/v1/token",
|
|
10
|
+
"scopes": [
|
|
11
|
+
"openid",
|
|
12
|
+
"profile"
|
|
13
|
+
],
|
|
14
|
+
"pkce": true
|
|
15
|
+
},
|
|
16
|
+
"client": {
|
|
17
|
+
"client_id": "env:OKTA_CLIENT_ID",
|
|
18
|
+
"client_secret": "env:OKTA_CLIENT_SECRET"
|
|
19
|
+
},
|
|
20
|
+
"export": {
|
|
21
|
+
"env": {
|
|
22
|
+
"access_token": "OKTA_ACCESS_TOKEN",
|
|
23
|
+
"refresh_token": "OKTA_REFRESH_TOKEN"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"name": "openai",
|
|
4
|
+
"display_name": "OpenAI",
|
|
5
|
+
"auth_type": "api_key",
|
|
6
|
+
"flow": "api_key_prompt",
|
|
7
|
+
"api_key": {
|
|
8
|
+
"input_mode": "prompt",
|
|
9
|
+
"header_name": "Authorization",
|
|
10
|
+
"header_prefix": "Bearer",
|
|
11
|
+
"env_var": "OPENAI_API_KEY"
|
|
12
|
+
},
|
|
13
|
+
"export": {
|
|
14
|
+
"env": {
|
|
15
|
+
"api_key": "OPENAI_API_KEY"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
authsome/cli.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Command-line interface for authsome.
|
|
2
|
+
|
|
3
|
+
Implements all commands defined in spec §18 using Click.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
import json as json_lib
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from authsome.client import AuthClient
|
|
15
|
+
from authsome.errors import AuthsomeError
|
|
16
|
+
from authsome.models.enums import ExportFormat, FlowType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ContextObj:
|
|
20
|
+
"""Context object passed to all commands."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, profile: Optional[str], json_output: bool, quiet: bool, no_color: bool):
|
|
23
|
+
self.profile = profile
|
|
24
|
+
self.json_output = json_output
|
|
25
|
+
self.quiet = quiet
|
|
26
|
+
self.no_color = no_color
|
|
27
|
+
self.client: Optional[AuthClient] = None
|
|
28
|
+
|
|
29
|
+
def initialize_client(self) -> AuthClient:
|
|
30
|
+
if self.client is None:
|
|
31
|
+
self.client = AuthClient(profile=self.profile)
|
|
32
|
+
return self.client
|
|
33
|
+
|
|
34
|
+
def print_json(self, data: Any) -> None:
|
|
35
|
+
click.echo(json_lib.dumps(data, indent=2))
|
|
36
|
+
|
|
37
|
+
def echo(self, message: str, err: bool = False, color: Optional[str] = None, nl: bool = True) -> None:
|
|
38
|
+
if self.quiet:
|
|
39
|
+
return
|
|
40
|
+
if self.no_color:
|
|
41
|
+
color = None
|
|
42
|
+
click.secho(message, err=err, fg=color, nl=nl)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
pass_ctx = click.make_pass_decorator(ContextObj)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_error_code(exc: Exception) -> int:
|
|
49
|
+
"""Map exceptions to standard exit codes per spec §18.3."""
|
|
50
|
+
if not isinstance(exc, AuthsomeError):
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
exc_name = exc.__class__.__name__
|
|
54
|
+
if exc_name == "ProviderNotFoundError":
|
|
55
|
+
return 3
|
|
56
|
+
if exc_name == "AuthenticationFailedError":
|
|
57
|
+
return 4
|
|
58
|
+
if exc_name == "CredentialMissingError":
|
|
59
|
+
return 5
|
|
60
|
+
if exc_name == "RefreshFailedError":
|
|
61
|
+
return 6
|
|
62
|
+
if exc_name == "StoreUnavailableError":
|
|
63
|
+
return 7
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def handle_errors(func):
|
|
68
|
+
"""Decorator to catch exceptions and exit with proper codes."""
|
|
69
|
+
@functools.wraps(func)
|
|
70
|
+
def wrapper(ctx_obj: ContextObj, *args, **kwargs):
|
|
71
|
+
try:
|
|
72
|
+
return func(ctx_obj, *args, **kwargs)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
if ctx_obj.json_output:
|
|
75
|
+
ctx_obj.print_json({
|
|
76
|
+
"error": exc.__class__.__name__,
|
|
77
|
+
"message": str(exc),
|
|
78
|
+
})
|
|
79
|
+
else:
|
|
80
|
+
ctx_obj.echo(f"Error: {exc}", err=True, color="red")
|
|
81
|
+
sys.exit(format_error_code(exc))
|
|
82
|
+
return wrapper
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group()
|
|
86
|
+
@click.option("--profile", help="Override the active profile.")
|
|
87
|
+
@click.option("--json", "json_output", is_flag=True, help="Output in machine-readable JSON format.")
|
|
88
|
+
@click.option("--quiet", is_flag=True, help="Suppress non-essential output.")
|
|
89
|
+
@click.option("--no-color", is_flag=True, help="Disable ANSI colors.")
|
|
90
|
+
@click.pass_context
|
|
91
|
+
def cli(ctx: click.Context, profile: Optional[str], json_output: bool, quiet: bool, no_color: bool) -> None:
|
|
92
|
+
"""Authsome: Portable local authentication library for AI agents and tools."""
|
|
93
|
+
logging.getLogger("authsome").setLevel(logging.WARNING if quiet else logging.INFO)
|
|
94
|
+
ctx.obj = ContextObj(profile, json_output, quiet, no_color)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cli.command()
|
|
98
|
+
@pass_ctx
|
|
99
|
+
@handle_errors
|
|
100
|
+
def init(ctx_obj: ContextObj) -> None:
|
|
101
|
+
"""Initialize the authsome root directory and default profile."""
|
|
102
|
+
client = ctx_obj.initialize_client()
|
|
103
|
+
client.init()
|
|
104
|
+
|
|
105
|
+
if ctx_obj.json_output:
|
|
106
|
+
ctx_obj.print_json({"status": "initialized", "home": str(client.home)})
|
|
107
|
+
else:
|
|
108
|
+
ctx_obj.echo(f"Initialized authsome at {client.home}", color="green")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@cli.command(name="list")
|
|
112
|
+
@pass_ctx
|
|
113
|
+
@handle_errors
|
|
114
|
+
def list_cmd(ctx_obj: ContextObj) -> None:
|
|
115
|
+
"""List providers and connection states."""
|
|
116
|
+
client = ctx_obj.initialize_client()
|
|
117
|
+
raw_list = client.list_connections()
|
|
118
|
+
|
|
119
|
+
providers_flat = []
|
|
120
|
+
for provider_group in raw_list:
|
|
121
|
+
pname = provider_group["name"]
|
|
122
|
+
for conn in provider_group["connections"]:
|
|
123
|
+
item = {
|
|
124
|
+
"name": pname,
|
|
125
|
+
"connection": conn["connection_name"],
|
|
126
|
+
"auth_type": conn.get("auth_type"),
|
|
127
|
+
"status": conn.get("status"),
|
|
128
|
+
}
|
|
129
|
+
if conn.get("scopes"):
|
|
130
|
+
item["scopes"] = conn["scopes"]
|
|
131
|
+
if conn.get("expires_at"):
|
|
132
|
+
item["expires_at"] = conn["expires_at"]
|
|
133
|
+
providers_flat.append(item)
|
|
134
|
+
|
|
135
|
+
if ctx_obj.json_output:
|
|
136
|
+
ctx_obj.print_json({
|
|
137
|
+
"profile": client.active_profile,
|
|
138
|
+
"providers": providers_flat,
|
|
139
|
+
})
|
|
140
|
+
else:
|
|
141
|
+
ctx_obj.echo(f"Profile: {client.active_profile}")
|
|
142
|
+
if not providers_flat:
|
|
143
|
+
ctx_obj.echo("No connections found.", color="yellow")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
for p in providers_flat:
|
|
147
|
+
name = p["name"]
|
|
148
|
+
conn_name = p["connection"]
|
|
149
|
+
status = p["status"]
|
|
150
|
+
color = "green" if status == "connected" else "red"
|
|
151
|
+
ctx_obj.echo(f" {name} ({conn_name}) - ", nl=False)
|
|
152
|
+
ctx_obj.echo(status, color=color)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@cli.command()
|
|
156
|
+
@click.argument("provider")
|
|
157
|
+
@click.option("--connection", default="default", help="Connection name.")
|
|
158
|
+
@click.option("--flow", help="Authentication flow override.")
|
|
159
|
+
@click.option("--scopes", help="Comma-separated scopes to request.")
|
|
160
|
+
@pass_ctx
|
|
161
|
+
@handle_errors
|
|
162
|
+
def login(ctx_obj: ContextObj, provider: str, connection: str, flow: Optional[str], scopes: Optional[str]) -> None:
|
|
163
|
+
"""Authenticate with a provider using its configured flow."""
|
|
164
|
+
client = ctx_obj.initialize_client()
|
|
165
|
+
flow_enum = FlowType(flow) if flow else None
|
|
166
|
+
scope_list = [s.strip() for s in scopes.split(",")] if scopes else None
|
|
167
|
+
|
|
168
|
+
if not ctx_obj.json_output:
|
|
169
|
+
ctx_obj.echo(f"Starting login for {provider}...", color="cyan")
|
|
170
|
+
|
|
171
|
+
record = client.login(
|
|
172
|
+
provider=provider,
|
|
173
|
+
connection_name=connection,
|
|
174
|
+
scopes=scope_list,
|
|
175
|
+
flow_override=flow_enum,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if ctx_obj.json_output:
|
|
179
|
+
ctx_obj.print_json({
|
|
180
|
+
"status": "success",
|
|
181
|
+
"provider": provider,
|
|
182
|
+
"connection": connection,
|
|
183
|
+
"record_status": record.status.value,
|
|
184
|
+
})
|
|
185
|
+
else:
|
|
186
|
+
ctx_obj.echo(f"Successfully logged in to {provider} ({connection}).", color="green")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@cli.command()
|
|
190
|
+
@click.argument("provider")
|
|
191
|
+
@click.option("--connection", default="default", help="Connection name.")
|
|
192
|
+
@pass_ctx
|
|
193
|
+
@handle_errors
|
|
194
|
+
def revoke(ctx_obj: ContextObj, provider: str, connection: str) -> None:
|
|
195
|
+
"""Revoke credentials remotely (if supported) and remove locally."""
|
|
196
|
+
client = ctx_obj.initialize_client()
|
|
197
|
+
client.revoke(provider, connection)
|
|
198
|
+
|
|
199
|
+
if ctx_obj.json_output:
|
|
200
|
+
ctx_obj.print_json({"status": "revoked", "provider": provider, "connection": connection})
|
|
201
|
+
else:
|
|
202
|
+
ctx_obj.echo(f"Revoked credentials for {provider} ({connection}).", color="green")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@cli.command()
|
|
206
|
+
@click.argument("provider")
|
|
207
|
+
@click.option("--connection", default="default", help="Connection name.")
|
|
208
|
+
@pass_ctx
|
|
209
|
+
@handle_errors
|
|
210
|
+
def remove(ctx_obj: ContextObj, provider: str, connection: str) -> None:
|
|
211
|
+
"""Remove local credential state without remote revocation."""
|
|
212
|
+
client = ctx_obj.initialize_client()
|
|
213
|
+
client.remove(provider, connection)
|
|
214
|
+
|
|
215
|
+
if ctx_obj.json_output:
|
|
216
|
+
ctx_obj.print_json({"status": "removed", "provider": provider, "connection": connection})
|
|
217
|
+
else:
|
|
218
|
+
ctx_obj.echo(f"Removed local credentials for {provider} ({connection}).", color="green")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@cli.command()
|
|
222
|
+
@click.argument("provider")
|
|
223
|
+
@click.option("--connection", default="default", help="Connection name.")
|
|
224
|
+
@click.option("--field", help="Return only a specific field.")
|
|
225
|
+
@click.option("--show-secret", is_flag=True, help="Reveal encrypted secrets.")
|
|
226
|
+
@pass_ctx
|
|
227
|
+
@handle_errors
|
|
228
|
+
def get(ctx_obj: ContextObj, provider: str, connection: str, field: Optional[str], show_secret: bool) -> None:
|
|
229
|
+
"""Return provider connection metadata by default."""
|
|
230
|
+
client = ctx_obj.initialize_client()
|
|
231
|
+
record = client.get_connection(provider, connection)
|
|
232
|
+
|
|
233
|
+
data = record.model_dump()
|
|
234
|
+
|
|
235
|
+
# Redact secrets unless requested
|
|
236
|
+
if not show_secret:
|
|
237
|
+
for secret_field in ["access_token", "refresh_token", "api_key", "client_secret"]:
|
|
238
|
+
if data.get(secret_field):
|
|
239
|
+
data[secret_field] = "***REDACTED***"
|
|
240
|
+
else:
|
|
241
|
+
for secret_field in ["access_token", "refresh_token", "api_key", "client_secret"]:
|
|
242
|
+
val = getattr(record, secret_field, None)
|
|
243
|
+
if val:
|
|
244
|
+
data[secret_field] = client.crypto.decrypt(val)
|
|
245
|
+
|
|
246
|
+
if field:
|
|
247
|
+
if field in data:
|
|
248
|
+
if ctx_obj.json_output:
|
|
249
|
+
ctx_obj.print_json({field: data[field]})
|
|
250
|
+
else:
|
|
251
|
+
ctx_obj.echo(str(data[field]))
|
|
252
|
+
else:
|
|
253
|
+
ctx_obj.echo(f"Field '{field}' not found.", err=True, color="red")
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
if ctx_obj.json_output:
|
|
258
|
+
ctx_obj.print_json(data)
|
|
259
|
+
else:
|
|
260
|
+
for k, v in data.items():
|
|
261
|
+
ctx_obj.echo(f"{k}: {v}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@cli.command()
|
|
265
|
+
@click.argument("provider")
|
|
266
|
+
@pass_ctx
|
|
267
|
+
@handle_errors
|
|
268
|
+
def inspect(ctx_obj: ContextObj, provider: str) -> None:
|
|
269
|
+
"""Return provider definition and local connection summary."""
|
|
270
|
+
client = ctx_obj.initialize_client()
|
|
271
|
+
definition = client.get_provider(provider)
|
|
272
|
+
|
|
273
|
+
data = definition.model_dump()
|
|
274
|
+
if ctx_obj.json_output:
|
|
275
|
+
ctx_obj.print_json(data)
|
|
276
|
+
else:
|
|
277
|
+
ctx_obj.echo(json_lib.dumps(data, indent=2))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@cli.command()
|
|
281
|
+
@click.argument("provider")
|
|
282
|
+
@click.option("--connection", default="default", help="Connection name.")
|
|
283
|
+
@click.option("--format", "export_format", type=click.Choice(["env", "shell", "json"]), default="env")
|
|
284
|
+
@pass_ctx
|
|
285
|
+
@handle_errors
|
|
286
|
+
def export(ctx_obj: ContextObj, provider: str, connection: str, export_format: str) -> None:
|
|
287
|
+
"""Export credential material in selected format."""
|
|
288
|
+
client = ctx_obj.initialize_client()
|
|
289
|
+
fmt = ExportFormat(export_format)
|
|
290
|
+
output = client.export(provider, connection, format=fmt)
|
|
291
|
+
|
|
292
|
+
# Do not apply color or structured wrapping here, just output exactly what is requested
|
|
293
|
+
if output:
|
|
294
|
+
click.echo(output)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@cli.command(context_settings=dict(ignore_unknown_options=True))
|
|
298
|
+
@click.option("--provider", "-p", multiple=True, help="Provider(s) to inject credentials for.")
|
|
299
|
+
@click.argument("command", nargs=-1, required=True)
|
|
300
|
+
@pass_ctx
|
|
301
|
+
@handle_errors
|
|
302
|
+
def run(ctx_obj: ContextObj, provider: list[str], command: tuple[str]) -> None:
|
|
303
|
+
"""Run a subprocess with injected exported credentials."""
|
|
304
|
+
client = ctx_obj.initialize_client()
|
|
305
|
+
# spec states "Repeated flags for provider", so `provider` is a tuple of strings due to multiple=True
|
|
306
|
+
result = client.run(list(command), providers=list(provider))
|
|
307
|
+
sys.exit(result.returncode)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@cli.command()
|
|
311
|
+
@click.argument("path")
|
|
312
|
+
@click.option("--force", is_flag=True, help="Force overwrite if provider exists.")
|
|
313
|
+
@pass_ctx
|
|
314
|
+
@handle_errors
|
|
315
|
+
def register(ctx_obj: ContextObj, path: str, force: bool) -> None:
|
|
316
|
+
"""Register a provider definition from a local JSON file path."""
|
|
317
|
+
import pathlib
|
|
318
|
+
client = ctx_obj.initialize_client()
|
|
319
|
+
|
|
320
|
+
filepath = pathlib.Path(path)
|
|
321
|
+
if not filepath.exists():
|
|
322
|
+
ctx_obj.echo(f"File not found: {path}", err=True, color="red")
|
|
323
|
+
sys.exit(1)
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
data = json_lib.loads(filepath.read_text(encoding="utf-8"))
|
|
327
|
+
from authsome.models.provider import ProviderDefinition
|
|
328
|
+
definition = ProviderDefinition.model_validate(data)
|
|
329
|
+
client.register_provider(definition, force=force)
|
|
330
|
+
|
|
331
|
+
if ctx_obj.json_output:
|
|
332
|
+
ctx_obj.print_json({"status": "registered", "provider": definition.name})
|
|
333
|
+
else:
|
|
334
|
+
ctx_obj.echo(f"Provider {definition.name} registered.", color="green")
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
ctx_obj.echo(f"Failed to register provider: {exc}", err=True, color="red")
|
|
337
|
+
sys.exit(1)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@cli.command()
|
|
341
|
+
@pass_ctx
|
|
342
|
+
@handle_errors
|
|
343
|
+
def whoami(ctx_obj: ContextObj) -> None:
|
|
344
|
+
"""Show the active profile and basic local context."""
|
|
345
|
+
client = ctx_obj.initialize_client()
|
|
346
|
+
data = {
|
|
347
|
+
"active_profile": client.active_profile,
|
|
348
|
+
"home_directory": str(client.home),
|
|
349
|
+
"encryption_mode": client.config.encryption.mode if client.config.encryption else "local_key",
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if ctx_obj.json_output:
|
|
353
|
+
ctx_obj.print_json(data)
|
|
354
|
+
else:
|
|
355
|
+
ctx_obj.echo(f"Active Profile: {data['active_profile']}")
|
|
356
|
+
ctx_obj.echo(f"Home Directory: {data['home_directory']}")
|
|
357
|
+
ctx_obj.echo(f"Encryption Mode: {data['encryption_mode']}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@cli.command()
|
|
361
|
+
@pass_ctx
|
|
362
|
+
@handle_errors
|
|
363
|
+
def doctor(ctx_obj: ContextObj) -> None:
|
|
364
|
+
"""Run health checks on directory layout and encryption."""
|
|
365
|
+
client = ctx_obj.initialize_client()
|
|
366
|
+
results = client.doctor()
|
|
367
|
+
|
|
368
|
+
if ctx_obj.json_output:
|
|
369
|
+
ctx_obj.print_json(results)
|
|
370
|
+
else:
|
|
371
|
+
all_ok = True
|
|
372
|
+
for key, val in results.items():
|
|
373
|
+
if key in ["issues", "providers_count", "profiles_count"]:
|
|
374
|
+
continue
|
|
375
|
+
status = "OK" if val else "FAIL"
|
|
376
|
+
color = "green" if val else "red"
|
|
377
|
+
if not val:
|
|
378
|
+
all_ok = False
|
|
379
|
+
ctx_obj.echo(f"{key}: ", nl=False)
|
|
380
|
+
ctx_obj.echo(status, color=color)
|
|
381
|
+
|
|
382
|
+
ctx_obj.echo(f"Providers Configured: {results.get('providers_count', 0)}")
|
|
383
|
+
ctx_obj.echo(f"Profiles: {results.get('profiles_count', 0)}")
|
|
384
|
+
|
|
385
|
+
issues = results.get("issues", [])
|
|
386
|
+
if issues:
|
|
387
|
+
ctx_obj.echo("\nIssues found:", color="red")
|
|
388
|
+
for issue in issues:
|
|
389
|
+
ctx_obj.echo(f" - {issue}", color="red")
|
|
390
|
+
|
|
391
|
+
if not all_ok:
|
|
392
|
+
sys.exit(1)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@cli.group(name="profile")
|
|
396
|
+
def profile_group() -> None:
|
|
397
|
+
"""Manage local profiles."""
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@profile_group.command(name="list")
|
|
402
|
+
@pass_ctx
|
|
403
|
+
@handle_errors
|
|
404
|
+
def profile_list(ctx_obj: ContextObj) -> None:
|
|
405
|
+
"""List local profiles."""
|
|
406
|
+
client = ctx_obj.initialize_client()
|
|
407
|
+
profiles = client.list_profiles()
|
|
408
|
+
active = client.active_profile
|
|
409
|
+
|
|
410
|
+
if ctx_obj.json_output:
|
|
411
|
+
ctx_obj.print_json({
|
|
412
|
+
"active": active,
|
|
413
|
+
"profiles": [p.model_dump(mode="json") for p in profiles]
|
|
414
|
+
})
|
|
415
|
+
else:
|
|
416
|
+
ctx_obj.echo("Profiles:")
|
|
417
|
+
for p in profiles:
|
|
418
|
+
mark = "*" if p.name == active else " "
|
|
419
|
+
ctx_obj.echo(f" {mark} {p.name} ({p.description or 'No description'})")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@profile_group.command(name="create")
|
|
423
|
+
@click.argument("name")
|
|
424
|
+
@pass_ctx
|
|
425
|
+
@handle_errors
|
|
426
|
+
def profile_create(ctx_obj: ContextObj, name: str) -> None:
|
|
427
|
+
"""Create a profile."""
|
|
428
|
+
client = ctx_obj.initialize_client()
|
|
429
|
+
metadata = client.create_profile(name)
|
|
430
|
+
|
|
431
|
+
if ctx_obj.json_output:
|
|
432
|
+
ctx_obj.print_json({"status": "created", "profile": metadata.model_dump(mode="json")})
|
|
433
|
+
else:
|
|
434
|
+
ctx_obj.echo(f"Profile '{name}' created.", color="green")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@profile_group.command(name="use")
|
|
438
|
+
@click.argument("name")
|
|
439
|
+
@pass_ctx
|
|
440
|
+
@handle_errors
|
|
441
|
+
def profile_use(ctx_obj: ContextObj, name: str) -> None:
|
|
442
|
+
"""Set the global default profile."""
|
|
443
|
+
client = ctx_obj.initialize_client()
|
|
444
|
+
client.set_default_profile(name)
|
|
445
|
+
|
|
446
|
+
if ctx_obj.json_output:
|
|
447
|
+
ctx_obj.print_json({"status": "default_changed", "profile": name})
|
|
448
|
+
else:
|
|
449
|
+
ctx_obj.echo(f"Default profile set to '{name}'.", color="green")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
if __name__ == "__main__":
|
|
453
|
+
cli()
|