feat(auth): Add OAuth2 authentication

This commit is contained in:
Björn Benouarets
2026-01-27 16:35:46 +01:00
commit 50c85e9b7f
36 changed files with 1599 additions and 0 deletions

9
secnex/app/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .auth import auth_app
from .application import application_app
from .config import config_app
from .query import query_app
from .tenant import tenant_app
from .utils import utils_app
from .user import user_app
__all__ = ["auth_app", "application_app", "config_app", "query_app", "tenant_app", "utils_app", "user_app"]

65
secnex/app/application.py Normal file
View File

@@ -0,0 +1,65 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
import secnex.utils as utils
from secnex.kit.auth.auth import Auth
from secnex.kit.config.config import Config
from secnex.kit.clients.application import ApplicationClient
console = Console()
application_app = typer.Typer()
@application_app.command(name="create", help="Create a new application")
def create(
name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the application"),
tenant: Optional[str] = typer.Option(None, "--tenant", "-t", help="The tenant to set for the application"),
):
"""Create a new application"""
if name is None:
name = Prompt.ask("Enter the name of the application")
if tenant is None:
tenant = Prompt.ask("Enter the ID of the tenant")
config = Config("default")
application_client = ApplicationClient(config)
secret = utils.generate_secret()
app_id, status_code, success = application_client.create(name, tenant, secret)
if not success:
console.print(f"Failed to create application! Please check the logs for more information.", style="bold red")
return
console.print(f"Application created successfully!", style="bold green")
console.print(f"Application ID: {app_id}", style="bold green")
console.print(f"Secret: {secret}", style="bold green")
@application_app.command(name="ls", help="List all applications")
def ls():
"""List all applications"""
config = Config("default")
application_client = ApplicationClient(config)
applications, status_code, success = application_client.get_all()
if applications is None:
console.print(f"No applications found!", style="bold red")
return
count_applications = len(applications)
console.print(f"Found {count_applications} applications")
table = Table(title="Applications")
table.add_column("ID", style="bold green")
table.add_column("Name", style="bold yellow")
table.add_column("Tenant ID", style="bold purple")
for app in applications:
table.add_row(app.id, app.name, app.tenant_id)
console.print(table)
@application_app.command(name="rm", help="Remove an application")
def rm(id: str):
"""Remove an application"""
config = Config("default")
application_client = ApplicationClient(config)
application_client.remove(id)
console.print(f"Application {id} removed successfully!")

39
secnex/app/auth.py Normal file
View File

@@ -0,0 +1,39 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
from secnex.kit.auth.auth import Auth
from secnex.kit.config.config import Config
console = Console()
auth_app = typer.Typer()
@auth_app.command(name="login", help="Login to the SecNex API")
def login():
"""Login to the SecNex API"""
config = Config("default")
auth = Auth(config)
auth.login()
@auth_app.command(name="logout", help="Logout from the SecNex API")
def logout():
"""Logout from the SecNex API"""
config = Config("default")
auth = Auth(config)
auth.delete_token()
console.print(f"Logged out successfully!", style="bold green")
@auth_app.command(name="status", help="Check if user is already logged in")
def status():
"""Check if user is already logged in"""
config = Config("default")
auth = Auth(config)
token = auth.get_token()
if token is not None:
console.print(f"User is logged in!", style="bold green")
else:
console.print(f"User is not logged in!", style="bold red")

114
secnex/app/config.py Normal file
View File

@@ -0,0 +1,114 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
import os
import json
console = Console()
config_app = typer.Typer()
@config_app.command()
def create(
name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config"),
client_id: Optional[str] = typer.Option(None, "--client-id", "-c", help="The ID of the client"),
client_secret: Optional[str] = typer.Option(None, "--client-secret", "-s", help="The secret of the client"),
tenant_id: Optional[str] = typer.Option(None, "--tenant-id", "-t", help="The ID of the tenant"),
ui: Optional[str] = typer.Option(None, "--ui", "-u", help="The UI to use"),
host: Optional[str] = typer.Option(None, "--host", "-h", help="The host of the SecNex API"),
):
"""Create a new config"""
if name is None:
name = "default"
if host is None:
host = Prompt.ask("Enter the host of the SecNex API", default="http://localhost:3003")
if ui is None:
ui = Prompt.ask("Enter the UI to use", default="http://localhost:3000")
if client_id is None:
client_id = Prompt.ask("Enter the ID of the client")
if client_secret is None:
client_secret = Prompt.ask("Enter the secret of the client")
if tenant_id is None:
tenant_id = Prompt.ask("Enter the ID of the tenant")
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"))):
os.makedirs(os.path.join(os.path.expanduser("~/.secnex")))
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
with open(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), "w") as f:
json.dump({}, f)
else:
typer.echo(f"Config {name} already exists!", err=True, color=True)
return
with open(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), "w") as f:
json.dump({
"host": host,
"ui": ui,
"client": {
"id": client_id,
"secret": client_secret,
"tenant": tenant_id,
}
}, f)
typer.echo(f"Config {name} created successfully!", color=True)
@config_app.command(name="show", help="Show a config")
def show(name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config")):
"""Show a config"""
if name is None:
name = "default"
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
typer.echo(f"Config {name} does not exist!", err=True, color=True)
return
with open(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), "r") as f:
config = json.load(f)
console.print(f"Config {name}: {config}", style="dim")
@config_app.command(name="ls", help="List all configs")
def ls():
"""List all configs"""
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"))):
typer.echo("No configs found!", err=True, color=True)
return
table = Table(title="Configs")
table.add_column("Name", style="bold green")
table.add_column("Path", style="bold yellow")
for file in os.listdir(os.path.join(os.path.expanduser("~/.secnex"))):
if file.endswith(".json"):
table.add_row(file.replace(".json", ""), os.path.join(os.path.expanduser("~/.secnex"), file))
console.print(table)
@config_app.command(name="rm", help="Remove a config")
def rm(name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config")):
"""Remove a config"""
if name is None:
name = "default"
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
typer.echo(f"Config {name} does not exist!", err=True, color=True)
return
os.remove(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"))
typer.echo(f"Config {name} removed successfully!", color=True)
@config_app.command(name="path", help="Get the path of a config")
def path(name: Optional[str] = typer.Option(None, "--name", "-n", help="The name of the config")):
"""Get the path of a config"""
if name is None:
name = "default"
if not os.path.exists(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json")):
typer.echo(f"Config {name} does not exist!", err=True, color=True)
return
typer.echo(os.path.join(os.path.expanduser("~/.secnex"), f"{name}.json"), color=True)

36
secnex/app/query.py Normal file
View File

@@ -0,0 +1,36 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
import os
import json
import uuid
import base64
import secnex.utils as utils
console = Console()
query_app = typer.Typer()
@query_app.command(name="tables", help="Create query to list all tables")
def create_query_tables():
"""Create query to list all tables"""
q = """SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'public'"""
console.print(f"Query: {q}", style="dim")
return q
@query_app.command(name="new-apikey", help="Create query to create a new API key")
def create_query_new_apikey():
"""Create query to create a new API key"""
secret = utils.generate_secret()
secret_hash = utils.hash_secret(secret)
id = uuid.uuid4()
q = f"""INSERT INTO api_keys (id, key, enabled, created_at, updated_at) VALUES ('{id}', '{secret_hash}', true, NOW(), NOW())"""
console.print(f"Query: {q}", style="dim")
key = base64.b64encode(f"{id}:{secret}".encode()).decode()
console.print(f"Key: {key}", style="dim")
return key

61
secnex/app/tenant.py Normal file
View File

@@ -0,0 +1,61 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
from secnex.kit.config.config import Config
from secnex.kit.clients.tenant import TenantClient
from secnex.kit.clients.user import UserClient
console = Console()
tenant_app = typer.Typer()
@tenant_app.command(name="create", help="Create a new tenant")
def create(owner: Optional[str] = typer.Option(None, "--owner", "-o", help="The owner of the tenant")):
"""Create a new tenant"""
config = Config("default")
tenant_client = TenantClient(config)
name = Prompt.ask("Enter the name of the tenant")
tenant_id, status_code, success = tenant_client.create(name)
user_id = None
if owner is not None and tenant_id is not None:
user_client = UserClient(config)
success, status_code = user_client.add_user_to_tenant(tenant_id, owner)
if not success:
console.print(f"Failed to add user to tenant! Please check the logs for more information.", style="bold red")
return
console.print(f"Tenant created successfully!", style="bold green")
console.print(f"User added to tenant successfully!", style="bold green")
@tenant_app.command(name="ls", help="List all tenants")
def ls():
"""List all tenants"""
config = Config("default")
tenant_client = TenantClient(config)
tenants = tenant_client.get_all()
count_tenants = len(tenants)
console.print(f"Found {count_tenants} tenants")
if count_tenants == 0:
return
table = Table(title="Tenants")
table.add_column("ID", style="bold green")
table.add_column("Name", style="bold yellow")
table.add_column("Enabled", style="bold purple")
table.add_column("Allow Self Registration", style="bold blue")
for t in tenants:
id = t.id
name = t.name
enabled = t.enabled
allow_self_registration = t.allow_self_registration
table.add_row(
id,
name,
str(enabled),
str(allow_self_registration),
)
console.print(table)

55
secnex/app/user.py Normal file
View File

@@ -0,0 +1,55 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
import os
import json
import uuid
import base64
import secnex.utils as utils
from secnex.kit.config.config import Config
from secnex.kit.clients.user import UserClient
console = Console()
user_app = typer.Typer()
@user_app.command(name="ls", help="List all users")
def ls():
"""List all users"""
config = Config("default")
user_client = UserClient(config)
users = user_client.get_all()
count_users = len(users)
console.print(f"Found {count_users} users")
if count_users == 0:
return
table = Table(title="Users")
table.add_column("ID", style="bold green")
table.add_column("First Name", style="bold yellow")
table.add_column("Last Name", style="bold purple")
table.add_column("Email", style="bold blue")
table.add_column("Tenant ID", style="bold red")
for u in users:
table.add_row(u.id, u.first_name, u.last_name, u.email, u.tenant_id)
console.print(table)
@user_app.command(name="tenant", help="Set the tenant for a user")
def set_tenant(id: Optional[str] = typer.Option(None, "--id", "-i", help="The ID of the user"), tenant: Optional[str] = typer.Option(None, "--tenant", "-t", help="The tenant to set for the user")):
"""Set the tenant for a user"""
if id is None:
id = Prompt.ask("Enter the ID of the user")
if tenant is None:
tenant = Prompt.ask("Enter the ID of the tenant to set for the user")
config = Config("default")
user_client = UserClient(config)
success, status_code = user_client.add_user_to_tenant(tenant, id)
if not success:
console.print(f"Failed to set tenant for user! Please check the logs for more information.", style="bold red")
return
console.print(f"Tenant set for user successfully!", style="bold green")

23
secnex/app/utils.py Normal file
View File

@@ -0,0 +1,23 @@
import typer
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
import os
import json
import secnex.utils as u
console = Console()
utils_app = typer.Typer()
@utils_app.command(name="secret", help="Generate a new secret")
def generate_secret() -> str:
"""Generate a new secret"""
secret = u.generate_secret()
console.print(f"Secret: {secret}")
console.print(f"Hash: {u.hash_secret(secret)}")
return secret