feat(P03): Argon2id hashing — fail-closed, t=3 m=65536 p=1 (REQ-334, D-228, C-7.2, security-engineer)
The full nova-idp-auth Lambda handler is included in this commit (sign_up, sign_in, create_session, request_password_reset, reset_password) since the hashing module and handler share one file. The Argon2id hashing + fail-closed logic is the security-engineer territory; the Lambda plumbing is backend-engineer. ---ci--- project: acdl phase: 3 milestone: v1.28 status: execute persona: security-engineer ---
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
"""Nova IdP auth Lambda — sign-up / sign-in / session (REQ-333, REQ-334).
|
||||
|
||||
Invoked via a Function URL (IAM auth) by the Nova CLI and consumer
|
||||
pipelines. Mirrors the ``contract_ingestor.py`` pattern: lazy
|
||||
``boto3.resource`` DynamoDB singleton, env-var table names,
|
||||
``NOVA_LAMBDA_LOCAL_BYPASS`` for local testing, ``__main__`` CLI block
|
||||
for dual-use (REQ-329).
|
||||
|
||||
## Argon2id password hashing (REQ-334, D-228, C-7.2)
|
||||
|
||||
Passwords are hashed with Argon2id via ``argon2-cffi``:
|
||||
|
||||
PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)
|
||||
|
||||
These are the OWASP minimum parameters (t=3, m=65536 KiB, p=1).
|
||||
Lambda memory **MUST be ≥ 512 MB** (Argon2id memory_cost ~64 MiB +
|
||||
runtime overhead).
|
||||
|
||||
**D-228 (amended) — fail-closed:** there is no maintained pure-Python
|
||||
Argon2 implementation; a pure-Python crypto fallback is a liability
|
||||
(weaker hashing, violates INV-16's spirit). If the ``argon2`` C
|
||||
extension fails to import, the Lambda **fails closed** —
|
||||
``_ARGON2_AVAILABLE`` is set ``False`` at cold-start, and
|
||||
:func:`hash_password` / :func:`verify_password` raise
|
||||
``Argon2UnavailableError``. The handler catches this and returns
|
||||
**HTTP 503** (``{"error": "argon2_unavailable"}``) — **no pure-Python
|
||||
fallback, no weak hash, no crash.** This is verified by the explicit
|
||||
``test_argon2_fail_closed`` test (C-1.2).
|
||||
|
||||
## No raw passwords anywhere (INV-16)
|
||||
|
||||
Raw passwords are NEVER:
|
||||
* written to DynamoDB (only ``password_hash`` is stored),
|
||||
* logged (the handler never logs the password argument),
|
||||
* put in traces / env vars / X-Ray segments.
|
||||
|
||||
Audit events (``auth.sign_up``, ``auth.sign_in``,
|
||||
``auth.session_created``) are emitted to stderr as JSON; they carry the
|
||||
``user_id`` / ``email`` but **never** the password.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argon2id — fail-closed import (REQ-334, D-228, C-7.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# try-import the C extension. If it fails (missing abi3 wheel, wrong
|
||||
# glibc, etc.), _ARGON2_AVAILABLE becomes False and hash/verify raise
|
||||
# Argon2UnavailableError. The handler returns 503. NO pure-Python fallback.
|
||||
_ARGON2_AVAILABLE = False
|
||||
_PasswordHasher = None
|
||||
|
||||
try: # pragma: no cover - import success path covered by round-trip test
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_PasswordHasher = PasswordHasher
|
||||
_ARGON2_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - exercised via mock in tests
|
||||
_ARGON2_AVAILABLE = False
|
||||
|
||||
# Define a stand-in so `verify_password` can raise the right type
|
||||
# even when argon2 isn't importable. VerifyMismatchError is only
|
||||
# raised by verify() which itself raises Argon2UnavailableError first.
|
||||
class VerifyMismatchError(Exception):
|
||||
"""Raised by verify_password when the password does not match."""
|
||||
|
||||
|
||||
class Argon2UnavailableError(Exception):
|
||||
"""Raised when the Argon2 C extension is unavailable (D-228 fail-closed).
|
||||
|
||||
The handler catches this and returns HTTP 503 — no pure-Python
|
||||
fallback, no weak hash.
|
||||
"""
|
||||
|
||||
|
||||
# OWASP-minimum Argon2id parameters (C-7.2):
|
||||
# time_cost=3, memory_cost=65536 KiB (64 MiB), parallelism=1
|
||||
_ARGON2_TIME_COST = 3
|
||||
_ARGON2_MEMORY_COST = 65536 # KiB
|
||||
_ARGON2_PARALLELISM = 1
|
||||
|
||||
|
||||
def _get_hasher():
|
||||
"""Return a PasswordHasher configured with the OWASP-min params.
|
||||
|
||||
Raises Argon2UnavailableError if the C extension is not loaded.
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE or _PasswordHasher is None:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to hash with a "
|
||||
"weak fallback (D-228 fail-closed)"
|
||||
)
|
||||
return _PasswordHasher(
|
||||
time_cost=_ARGON2_TIME_COST,
|
||||
memory_cost=_ARGON2_MEMORY_COST,
|
||||
parallelism=_ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password with Argon2id (OWASP-min params).
|
||||
|
||||
Returns the Argon2id hash string (includes the salt + params).
|
||||
|
||||
Raises:
|
||||
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||
importable (D-228 fail-closed — NO pure-Python fallback).
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to hash (D-228)"
|
||||
)
|
||||
# NOTE: the password argument is NEVER logged. Do not add debug
|
||||
# prints here that include `password`.
|
||||
return _get_hasher().hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hash_str: str) -> bool:
|
||||
"""Verify a password against an Argon2id hash.
|
||||
|
||||
Returns ``True`` if the password matches.
|
||||
|
||||
Raises:
|
||||
Argon2UnavailableError: if the ``argon2`` C extension is not
|
||||
importable.
|
||||
VerifyMismatchError: if the password does not match the hash.
|
||||
"""
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError(
|
||||
"argon2 C extension unavailable — refusing to verify (D-228)"
|
||||
)
|
||||
# argon2.PasswordHasher().verify raises VerifyMismatchError on
|
||||
# mismatch (and InvalidHash on a malformed hash). We let those
|
||||
# propagate; the handler maps them to 401 / 500.
|
||||
_get_hasher().verify(hash_str, password)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config (env-var table names, mirroring contract_ingestor.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USERS_TABLE = os.environ.get("NOVA_USERS_TABLE", "nova-users")
|
||||
SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions")
|
||||
PASSWORD_RESETS_TABLE = os.environ.get(
|
||||
"NOVA_PASSWORD_RESETS_TABLE", "nova-password-resets"
|
||||
)
|
||||
# Session lifetime (seconds). Default 24h.
|
||||
SESSION_TTL_SECONDS = int(os.environ.get("NOVA_SESSION_TTL_SECONDS", "86400"))
|
||||
# Password-reset token lifetime (seconds). Default 15 min.
|
||||
RESET_TTL_SECONDS = int(os.environ.get("NOVA_RESET_TTL_SECONDS", "900"))
|
||||
|
||||
_dynamodb = None
|
||||
|
||||
|
||||
def _get_dynamodb():
|
||||
"""Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor)."""
|
||||
global _dynamodb
|
||||
if _dynamodb is None:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
return _dynamodb
|
||||
|
||||
|
||||
def _iso8601_now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
|
||||
|
||||
def _epoch_now() -> int:
|
||||
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _emit_audit(event_type: str, **fields) -> None:
|
||||
"""Emit an audit event to stderr as JSON (never includes passwords)."""
|
||||
payload = {"event": event_type, "ts": _iso8601_now(), **fields}
|
||||
# Defense-in-depth: scrub any field literally named 'password' or
|
||||
# 'password_hash' value from the audit payload (they should never be
|
||||
# passed here, but a stray kwarg would leak — INV-16).
|
||||
for _k in ("password", "new_password", "old_password"):
|
||||
payload.pop(_k, None)
|
||||
sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Business logic (sign_up / sign_in / create_session / reset flows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require(fields, payload):
|
||||
"""Validate required fields; raise ValueError (→ 400) if missing."""
|
||||
for f in fields:
|
||||
if f not in payload or payload[f] in (None, ""):
|
||||
raise ValueError(f"missing field: {f}")
|
||||
|
||||
|
||||
def _lookup_user_by_email(email: str):
|
||||
"""Query nova-users GSI1 (email-index) → return the user item or None."""
|
||||
table = _get_dynamodb().Table(USERS_TABLE)
|
||||
resp = table.query(
|
||||
IndexName="email-index",
|
||||
KeyConditionExpression="email = :e",
|
||||
ExpressionAttributeValues={":e": email},
|
||||
Limit=1,
|
||||
)
|
||||
items = resp.get("Items", [])
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def sign_up(payload):
|
||||
"""Create a new user. Fails closed (503) if argon2 is unavailable.
|
||||
|
||||
Payload: { email, password, owner, roles }
|
||||
Writes to nova-users: PK user_id (uuid4), email, password_hash,
|
||||
owner, roles, created_at. The raw password is NEVER stored.
|
||||
"""
|
||||
_require(("email", "password", "owner", "roles"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
email = payload["email"]
|
||||
password = payload["password"]
|
||||
owner = payload["owner"]
|
||||
roles = payload["roles"]
|
||||
if not isinstance(roles, list):
|
||||
raise ValueError("roles must be a list")
|
||||
|
||||
# Duplicate-email check → 409.
|
||||
if _lookup_user_by_email(email) is not None:
|
||||
raise _DuplicateEmailError(email)
|
||||
|
||||
user_id = str(uuid.uuid4())
|
||||
password_hash = hash_password(password) # fail-closed here
|
||||
created_at = _iso8601_now()
|
||||
item = {
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"password_hash": password_hash,
|
||||
"owner": owner,
|
||||
"roles": roles,
|
||||
"created_at": created_at,
|
||||
}
|
||||
table = _get_dynamodb().Table(USERS_TABLE)
|
||||
table.put_item(TableName=USERS_TABLE, Item=item)
|
||||
_emit_audit("auth.sign_up", user_id=user_id, email=email)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "sign_up",
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
|
||||
class _DuplicateEmailError(Exception):
|
||||
"""Raised when sign_up is called with an already-registered email → 409."""
|
||||
|
||||
def __init__(self, email: str):
|
||||
self.email = email
|
||||
super().__init__(f"email already registered: {email}")
|
||||
|
||||
|
||||
def create_session(user_id: str) -> str:
|
||||
"""Create a session row in nova-sessions; return the session_id.
|
||||
|
||||
TTL: expires_at = now + SESSION_TTL_SECONDS (epoch seconds).
|
||||
"""
|
||||
session_id = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
expires_at = now + SESSION_TTL_SECONDS
|
||||
created_at = _iso8601_now()
|
||||
table = _get_dynamodb().Table(SESSIONS_TABLE)
|
||||
table.put_item(
|
||||
TableName=SESSIONS_TABLE,
|
||||
Item={
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
"created_at": created_at,
|
||||
},
|
||||
)
|
||||
_emit_audit("auth.session_created", user_id=user_id, session_id=session_id)
|
||||
return session_id
|
||||
|
||||
|
||||
def sign_in(payload):
|
||||
"""Sign in by email + password → return a session_id.
|
||||
|
||||
On wrong password → raises VerifyMismatchError (→ 401).
|
||||
On unknown email → raises _UnknownUserError (→ 401, same code to
|
||||
avoid user-enumeration via timing — the message is generic).
|
||||
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||
"""
|
||||
_require(("email", "password"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
email = payload["email"]
|
||||
password = payload["password"]
|
||||
user = _lookup_user_by_email(email)
|
||||
if user is None:
|
||||
# Generic 401 — do not reveal whether the email is registered
|
||||
# (user-enumeration defense).
|
||||
raise _UnknownUserError("invalid credentials")
|
||||
try:
|
||||
verify_password(password, user["password_hash"])
|
||||
except VerifyMismatchError:
|
||||
raise _UnknownUserError("invalid credentials")
|
||||
session_id = create_session(user["user_id"])
|
||||
_emit_audit("auth.sign_in", user_id=user["user_id"], email=email)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "sign_in",
|
||||
"user_id": user["user_id"],
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
class _UnknownUserError(Exception):
|
||||
"""Generic 'invalid credentials' — 401 (no user enumeration)."""
|
||||
|
||||
|
||||
def request_password_reset(payload):
|
||||
"""Generate a reset token (uuid4) → write to nova-password-resets (15 min TTL).
|
||||
|
||||
Returns the token directly (in a real system this would be emailed;
|
||||
for v1.28 it is returned so tests / the CLI can drive reset_password).
|
||||
"""
|
||||
_require(("email",), payload)
|
||||
email = payload["email"]
|
||||
user = _lookup_user_by_email(email)
|
||||
if user is None:
|
||||
# Return ok regardless (no user enumeration via reset endpoint).
|
||||
# We still return a (fake) token shape so the response is uniform;
|
||||
# the token is single-use and reset_password validates against DDB.
|
||||
_emit_audit("auth.password_reset_requested", email=email, found=False)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "request_password_reset",
|
||||
"reset_token": None,
|
||||
"message": "if the email is registered, a reset token was issued",
|
||||
}
|
||||
reset_token = str(uuid.uuid4())
|
||||
now = _epoch_now()
|
||||
expires_at = now + RESET_TTL_SECONDS
|
||||
table = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||
table.put_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Item={
|
||||
"reset_token": reset_token,
|
||||
"user_id": user["user_id"],
|
||||
"expires_at": expires_at,
|
||||
"created_at": _iso8601_now(),
|
||||
},
|
||||
)
|
||||
_emit_audit(
|
||||
"auth.password_reset_requested",
|
||||
user_id=user["user_id"],
|
||||
email=email,
|
||||
found=True,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "request_password_reset",
|
||||
"reset_token": reset_token,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
def reset_password(payload):
|
||||
"""Validate a reset token → set a new password → delete the token.
|
||||
|
||||
Payload: { reset_token, new_password }
|
||||
On invalid/expired token → ValueError (→ 400).
|
||||
On argon2 unavailable → Argon2UnavailableError (→ 503).
|
||||
"""
|
||||
_require(("reset_token", "new_password"), payload)
|
||||
if not _ARGON2_AVAILABLE:
|
||||
raise Argon2UnavailableError("argon2 unavailable")
|
||||
reset_token = payload["reset_token"]
|
||||
new_password = payload["new_password"]
|
||||
resets = _get_dynamodb().Table(PASSWORD_RESETS_TABLE)
|
||||
resp = resets.get_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Key={"reset_token": reset_token},
|
||||
)
|
||||
item = resp.get("Item")
|
||||
if not item:
|
||||
raise ValueError("invalid or expired reset token")
|
||||
if item.get("expires_at", 0) < _epoch_now():
|
||||
# Token expired (TTL may not have reaped it yet).
|
||||
raise ValueError("reset token expired")
|
||||
user_id = item["user_id"]
|
||||
new_hash = hash_password(new_password) # fail-closed
|
||||
users = _get_dynamodb().Table(USERS_TABLE)
|
||||
users.update_item(
|
||||
TableName=USERS_TABLE,
|
||||
Key={"user_id": user_id},
|
||||
UpdateExpression="SET password_hash = :h",
|
||||
ExpressionAttributeValues={":h": new_hash},
|
||||
)
|
||||
resets.delete_item(
|
||||
TableName=PASSWORD_RESETS_TABLE,
|
||||
Key={"reset_token": reset_token},
|
||||
)
|
||||
_emit_audit("auth.password_reset", user_id=user_id)
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": "reset_password",
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch (shared by Lambda handler + CLI — REQ-329 dual-use)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dispatch_action(payload, event=None):
|
||||
"""Shared business-logic dispatch for the IdP auth Lambda (REQ-329).
|
||||
|
||||
Both the AWS Lambda handler (``lambda_handler``) and the CLI path
|
||||
(``cli_main`` / ``__main__``) call this so the two paths share a
|
||||
single source of truth for action routing.
|
||||
|
||||
Args:
|
||||
payload: the decoded action envelope dict, e.g.
|
||||
``{ action: "sign_up", email, password, owner, roles }``.
|
||||
event: the raw Lambda Function-URL event (unused for identity —
|
||||
the IAM auth is enforced at the Function URL layer; kept for
|
||||
signature symmetry with contract_ingestor).
|
||||
|
||||
Returns:
|
||||
The action result dict on success. Raises on error — the caller
|
||||
maps exceptions to status codes via :func:`_to_http_response`.
|
||||
"""
|
||||
action = payload.get("action")
|
||||
if action == "sign_up":
|
||||
return sign_up(payload)
|
||||
if action == "sign_in":
|
||||
return sign_in(payload)
|
||||
if action == "create_session":
|
||||
_require(("user_id",), payload)
|
||||
sid = create_session(payload["user_id"])
|
||||
return {"status": "ok", "action": "create_session", "session_id": sid}
|
||||
if action == "request_password_reset":
|
||||
return request_password_reset(payload)
|
||||
if action == "reset_password":
|
||||
return reset_password(payload)
|
||||
raise ValueError(f"unknown action: {action!r}")
|
||||
|
||||
|
||||
def _to_http_response(result_or_error):
|
||||
"""Map a dispatch result / exception to a Lambda HTTP response."""
|
||||
if isinstance(result_or_error, Exception):
|
||||
# Fail-closed: argon2 unavailable → 503 (NO weak hash, NO crash).
|
||||
if isinstance(result_or_error, Argon2UnavailableError):
|
||||
return {
|
||||
"statusCode": 503,
|
||||
"body": json.dumps({"error": "argon2_unavailable"}),
|
||||
}
|
||||
if isinstance(result_or_error, _DuplicateEmailError):
|
||||
return {
|
||||
"statusCode": 409,
|
||||
"body": json.dumps({"error": "email_already_registered"}),
|
||||
}
|
||||
if isinstance(result_or_error, _UnknownUserError):
|
||||
return {
|
||||
"statusCode": 401,
|
||||
"body": json.dumps({"error": "invalid_credentials"}),
|
||||
}
|
||||
if isinstance(result_or_error, ValueError):
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"body": json.dumps({"error": str(result_or_error)}),
|
||||
}
|
||||
return {"statusCode": 200, "body": json.dumps(result_or_error)}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use).
|
||||
|
||||
Accepts a Function-URL-style event whose ``body`` is a JSON string
|
||||
containing ``{ action, email, password, ... }``. Parses the envelope
|
||||
then delegates to :func:`dispatch_action`.
|
||||
"""
|
||||
# Fail-closed fast-path: if argon2 is unavailable, sign_up / sign_in /
|
||||
# reset_password all raise Argon2UnavailableError which maps to 503.
|
||||
# We do NOT short-circuit here so non-password actions (create_session)
|
||||
# still work when argon2 is down — only the hashing paths fail closed.
|
||||
try:
|
||||
body = event.get("body", "{}")
|
||||
payload = json.loads(body) if isinstance(body, str) else body
|
||||
result = dispatch_action(payload, event=event)
|
||||
return _to_http_response(result)
|
||||
except Exception as e:
|
||||
return _to_http_response(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI (dual-use, REQ-329 pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point for the IdP auth Lambda (REQ-329 dual-use).
|
||||
|
||||
Usage:
|
||||
python3 -m core.lambda.nova_idp_auth --sign-up <email> <password> <owner>
|
||||
python3 -m core.lambda.nova_idp_auth --sign-in <email> <password>
|
||||
python3 -m core.lambda.nova_idp_auth --create-session <user_id>
|
||||
python3 -m core.lambda.nova_idp_auth --request-reset <email>
|
||||
python3 -m core.lambda.nova_idp_auth --reset-password <token> <new_password>
|
||||
python3 -m core.lambda.nova_idp_auth --dispatch <payload.json>
|
||||
python3 -m core.lambda.nova_idp_auth --dispatch-stdin < <payload.json>
|
||||
"""
|
||||
import sys
|
||||
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS")
|
||||
if not local_bypass:
|
||||
os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1"
|
||||
try:
|
||||
if "--dispatch-stdin" in raw:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
elif "--dispatch" in raw:
|
||||
idx = raw.index("--dispatch")
|
||||
path = raw[idx + 1] if idx + 1 < len(raw) else None
|
||||
if not path:
|
||||
print("Usage: --dispatch <payload.json>", file=sys.stderr)
|
||||
return 2
|
||||
with open(path) as fh:
|
||||
payload = json.loads(fh.read())
|
||||
elif "--sign-up" in raw:
|
||||
idx = raw.index("--sign-up")
|
||||
email, password, owner = raw[idx + 1 : idx + 4]
|
||||
roles = ["user"]
|
||||
payload = {
|
||||
"action": "sign_up",
|
||||
"email": email,
|
||||
"password": password,
|
||||
"owner": owner,
|
||||
"roles": roles,
|
||||
}
|
||||
elif "--sign-in" in raw:
|
||||
idx = raw.index("--sign-in")
|
||||
email, password = raw[idx + 1 : idx + 3]
|
||||
payload = {"action": "sign_in", "email": email, "password": password}
|
||||
elif "--create-session" in raw:
|
||||
idx = raw.index("--create-session")
|
||||
user_id = raw[idx + 1]
|
||||
payload = {"action": "create_session", "user_id": user_id}
|
||||
elif "--request-reset" in raw:
|
||||
idx = raw.index("--request-reset")
|
||||
email = raw[idx + 1]
|
||||
payload = {"action": "request_password_reset", "email": email}
|
||||
elif "--reset-password" in raw:
|
||||
idx = raw.index("--reset-password")
|
||||
token, new_password = raw[idx + 1 : idx + 3]
|
||||
payload = {
|
||||
"action": "reset_password",
|
||||
"reset_token": token,
|
||||
"new_password": new_password,
|
||||
}
|
||||
else:
|
||||
print(
|
||||
"Usage: python3 -m core.lambda.nova_idp_auth "
|
||||
"--sign-up <email> <password> <owner> | "
|
||||
"--sign-in <email> <password> | "
|
||||
"--dispatch <payload.json>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
result = dispatch_action(payload, event=None)
|
||||
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
||||
return 0
|
||||
except Argon2UnavailableError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 3 # 503-class
|
||||
except ValueError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1
|
||||
except _DuplicateEmailError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 9 # 409-class
|
||||
except _UnknownUserError as e:
|
||||
sys.stderr.write(f"error: {e}\n")
|
||||
return 1 # 401-class
|
||||
except Exception as e: # pragma: no cover - defensive top-level guard
|
||||
sys.stderr.write(f"internal error: {e}\n")
|
||||
return 2
|
||||
finally:
|
||||
if not local_bypass:
|
||||
os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
import sys
|
||||
|
||||
sys.exit(cli_main())
|
||||
Reference in New Issue
Block a user