Skip to content

✨ [SC-2532] auth: Add OAuth2 PKCE login command#13

Closed
woakas wants to merge 5 commits into
masterfrom
back/SC-2532__cli-oauth-login
Closed

✨ [SC-2532] auth: Add OAuth2 PKCE login command#13
woakas wants to merge 5 commits into
masterfrom
back/SC-2532__cli-oauth-login

Conversation

@woakas
Copy link
Copy Markdown
Member

@woakas woakas commented May 12, 2026

Resumen

Slice vertical M2 del epic #2517 OAuth2 Third-Party Apps, combinando tres tickets de Shortcut en un solo PR para que el flow ubidots login quede validable end-to-end:

  • SC-2531 — Extiende ProfileConfigModel con campos OAuth opcionales (oauth_client_id, refresh_token, expires_at, scope, token_type) y AuthHeaderTypeEnum.OAUTH2 = "Authorization". Perfiles legacy con auth_method=X-Auth-Token siguen cargando intactos. Archivos de profile se persisten con 0600 y se tightenean al leer si están más abiertos.
  • SC-2535 — Centraliza el dispatch de header HTTP en cli/commons/http_auth.py::get_auth_headers. build_endpoint y get_runtimes_from_api delegan en el helper. Test de auditoría falla la build si reaparece X-Auth-Token hardcoded fuera del enum, el helper, o la CORS allowlist de UbiFunctions (que no es auth header del CLI).
  • SC-2532 — Comando ubidots login con Authorization Code + PKCE (S256, verifier 64B, state 32B). Loopback en 127.0.0.1:53682/callback, timeout 60s configurable, state CSRF chequeado, flags --client-id, --profile, --no-browser, --scope, --timeout. Resolución de client_id con cascada: flag > env var UBIDOTS_OAUTH_CLIENT_ID > oauth_client_id del profile > settings.OAUTH.DEFAULT_CLIENT_ID.

Fuera de alcance (próximos PRs)

  • SC-2534 — refresh wrapper transparente con filelock
  • SC-2533ubidots logout + whoami
  • SC-2536 — E2E tests + docs/authentication.md + CHANGELOG

Pendiente de DevOps antes de mergear

DevOps tiene que registrar la OAuth Application en core (ubidots) con:

python manage.py create_oauth_application \
  --client-id ubidots-cli \
  --name "Ubidots CLI" \
  --client-type public \
  --grant-type authorization-code \
  --redirect-uris http://127.0.0.1:53682/callback

Cuando esté creada, hardcodear el client_id resultante en cli/settings.py::OAuthSettings.DEFAULT_CLIENT_ID (hoy vacío). Mientras tanto, ubidots login necesita --client-id <id> o UBIDOTS_OAUTH_CLIENT_ID=<id> exportado.

Test plan

  • 43 tests unitarios nuevos pasan (auth/, commons/tests/test_http_auth.py, config/tests/test_oauth_profile_model.py)
  • Suite completa: 457 pasan, 11 fallan pre-existentes en master (todas en cli/pages/tests/test_cloud_commands.py, no introducidas por este PR)
  • ruff check con target-version = "py314" limpio en todos los archivos del cambio
  • ubidots --help lista login y ubidots login --help muestra los flags correctos
  • Smoke test E2E manual contra core staging una vez DevOps registre la OAuth Application
  • Validar ubidots devices list con perfil OAuth contra core staging

Notas técnicas

  • Sin client_secret: cliente público (RFC 8252 §8.5) — PKCE reemplaza al secret.
  • Sin nueva dependencia: la spec sugería authlib, pero con httpx (ya dep) + http.server (stdlib) basta para el alcance de este PR. Re-evaluar cuando llegue SC-2534.
  • Python 3.14-ready: ruff target-version = "py314", from __future__ import annotations donde aplica para que las forward refs sean lazy en runtime actual (3.12) y bajo PEP 649 en 3.14.
  • Retrocompat: la línea assert no_oauth_command_breaks_token_profile no es una regla externa — está cubierta por la suite (TestProfileConfigModelLegacyLoad, TestValidateProfileConfigBackwardCompat, TestGetAuthHeaders).

Summary by CodeRabbit

Release Notes

  • New Features

    • Added ubidots auth login command supporting OAuth2 authentication with browser-based sign-in or manual URL flow.
    • Added OAuth2 profile support with automatic token refresh handling.
    • Added --no-browser flag for environments without browser access.
  • Security

    • Profile configuration files now enforced with secure file permissions (0600) on Unix systems.
    • Added CSRF protection during authentication flows.

Review Change Stack

Slice vertical M2 (epic #2517 OAuth2 Third-Party Apps) combinando:

- SC-2531 — Extiende ProfileConfigModel con campos OAuth opcionales
  (oauth_client_id, refresh_token, expires_at, scope, token_type)
  + AuthHeaderTypeEnum.OAUTH2 = "Authorization". Perfiles legacy
  TOKEN siguen cargando intactos. Perms 0600 + warning si están
  más abiertos.

- SC-2535 — Centraliza el dispatch de header HTTP en
  cli/commons/http_auth.py::get_auth_headers. build_endpoint y
  get_runtimes_from_api delegan. Test de auditoría falla la build
  si reaparece X-Auth-Token hardcoded fuera del enum / helper /
  CORS allowlist de UbiFunctions.

- SC-2532 — Comando 'ubidots login' con Authorization Code + PKCE
  (S256, secrets.token_urlsafe). Loopback en 127.0.0.1:53682,
  timeout 60s, state CSRF, --no-browser, --profile, --scope,
  --timeout. Resolución de client_id: flag > env var
  UBIDOTS_OAUTH_CLIENT_ID > profile > settings.OAUTH.DEFAULT_CLIENT_ID.

Out of scope (próximos PRs):
- SC-2534 — refresh wrapper transparente con filelock
- SC-2533 — logout + whoami
- SC-2536 — E2E + docs

Tests: 43 nuevos en verde, 0 regresiones.
Ruff target subido a py314.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Warning

Rate limit exceeded

@woakas has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 40 minutes and 45 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3fd56296-1e31-4691-95c1-dc415cb6090f

📥 Commits

Reviewing files that changed from the base of the PR and between 094362c and 46f29e3.

📒 Files selected for processing (6)
  • cli/auth/commands.py
  • cli/auth/oauth_client.py
  • cli/auth/tests/test_commands.py
  • cli/auth/tests/test_oauth_client.py
  • cli/settings.py
  • pyproject.toml
📝 Walkthrough

Walkthrough

This PR implements a complete OAuth2 Authorization Code + PKCE flow for CLI authentication. The implementation includes loopback callback handling, secure token exchange, CSRF protection, and profile persistence with hardened file permissions. The login command integrates cleanly with existing profile/configuration infrastructure and is registered as a top-level CLI command.

Changes

OAuth2 Authorization Code + PKCE Flow

Layer / File(s) Summary
OAuth Settings & Configuration Foundation
cli/settings.py, cli/config/models.py, cli/commons/exceptions.py
OAuthSettings defines loopback host/port, callback/token paths, timeouts, and scope defaults. ProfileConfigModel gains OAuth2 fields (oauth_client_id, refresh_token, expires_at, scope, token_type) with a @model_validator enforcing required fields when auth_method is OAUTH2; AuthHeaderTypeEnum adds OAUTH2 = "Authorization" for bearer tokens.
OAuth Exceptions & Auth Headers
cli/commons/exceptions.py, cli/commons/http_auth.py
New exceptions cover OAuth authorization denial, timeouts, CSRF mismatches, and token exchange errors; get_auth_headers() conditionally formats OAuth2 bearer or token-header-style auth based on config; tests verify correct header generation and enforce X-Auth-Token is not hardcoded outside allowed files.
OAuth Client: PKCE & Token Exchange
cli/auth/oauth_client.py, cli/auth/tests/test_oauth_client.py
PKCEPair and TokenSet dataclasses represent PKCE material and token responses; generate_pkce_pair() creates RFC 7636 S256 verifier/challenge, build_authorize_url() composes authorization endpoint URLs using 127.0.0.1 per RFC 8252, and exchange_code_for_tokens() POST-exchanges authorization codes for tokens with error handling for 401/400 responses.
Loopback Server & OAuth Callback Handler
cli/auth/loopback_server.py, cli/auth/tests/test_loopback_server.py
LoopbackServer extends HTTPServer with thread-safe callback storage, daemon-threaded serve_forever, and timeout-aware wait_for_callback() that blocks until result or raises LoginTimeoutError; _CallbackHandler validates callback path, parses code/error/state query params, and returns success/denied HTML; assert_state_matches() enforces CSRF state validation; port_available() checks port bindability.
Login Command Orchestration
cli/auth/commands.py, cli/auth/tests/test_commands.py
Typer-based login command resolves active profile and client ID (flag → env var → config → default), verifies loopback port is free, generates PKCE/state, builds authorization URL, spawns loopback server, optionally opens browser or prints URL, waits for callback with CSRF validation, exchanges code for tokens, extracts user label from JWT, persists OAuth profile, and prints success/error messages; test suite covers happy path, error paths (state mismatch, port conflict), file permissions, and client ID precedence.
Profile Persistence & File Security
cli/config/helpers.py, cli/commons/utils.py, cli/commons/tests/test_http_auth.py, cli/config/tests/test_oauth_profile_model.py
save_profile_configuration() writes YAML via os.open/fdopen and enforces 0600 file mode on POSIX; _warn_if_permissive() warns and tightens existing permissive files; profile validation relaxes required fields to exclude OAuth optionals; get_runtimes_from_api() uses centralized get_auth_headers() instead of hardcoded headers; comprehensive tests validate file permissions, token/OAuth profile serialization round-trips, and backward compatibility.
CLI Integration & Build Config
cli/main.py, pyproject.toml
Login command registered as top-level Typer command with OAuth2 help text; Python target version updated to py314 in Ruff configuration.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LoginCmd as login command
  participant Config
  participant LoopbackSrv as LoopbackServer
  participant OAuthSrv as OAuth Server
  User->>LoginCmd: ubidots cli login [--flags]
  LoginCmd->>Config: resolve_active_profile()
  LoginCmd->>LoginCmd: resolve_client_id(flag, env, config)
  LoginCmd->>LoginCmd: port_available(host, port)
  LoginCmd->>LoginCmd: generate_pkce_pair()
  LoginCmd->>LoginCmd: generate_state()
  LoginCmd->>LoginCmd: build_authorize_url()
  LoginCmd->>LoopbackSrv: LoopbackServer.wait_for_callback()
  alt --no-browser
    LoginCmd-->>User: print authorization URL
  else default
    LoginCmd->>User: open browser to authorization URL
  end
  User->>OAuthSrv: authorize application
  OAuthSrv->>LoopbackSrv: redirect with code & state
  LoopbackSrv->>LoopbackSrv: assert_state_matches()
  LoopbackSrv-->>LoginCmd: return LoopbackResult
  LoginCmd->>OAuthSrv: exchange_code_for_tokens()
  OAuthSrv-->>LoginCmd: TokenSet (tokens, expires_at)
  LoginCmd->>LoginCmd: extract_user_label(access_token)
  LoginCmd->>Config: save_profile_configuration(oauth_fields)
  LoginCmd-->>User: print success message
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ubidots/ubidots-cli#9: Modifies ProfileConfigModel in cli/config/models.py with profile field additions; this PR extends the same model with OAuth-related fields and validation.

Suggested reviewers

  • gajaguar

Poem

🐰 A rabbit hops through OAuth's flow so fine,
With PKCE hops and state so divine,
Tokens exchanged through loopback so neat,
Profiles persisted, the auth dance complete! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an OAuth2 PKCE login command, which is the primary feature introduced across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch back/SC-2532__cli-oauth-login

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
cli/commons/tests/test_http_auth.py (1)

61-65: 💤 Low value

Consider using frozenset for EXCLUDED_FILES.

The static analyzer flags this as a mutable class attribute. Since EXCLUDED_FILES is a constant, using frozenset would make the immutability explicit and silence the warning.

♻️ Proposed change
-    EXCLUDED_FILES = {
+    EXCLUDED_FILES = frozenset({
         "cli/config/models.py",
         "cli/commons/http_auth.py",
         "cli/functions/engines/models.py",
-    }
+    })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/commons/tests/test_http_auth.py` around lines 61 - 65, EXCLUDED_FILES is
defined as a mutable set literal; replace it with an immutable frozenset to make
the constant explicit and silence the static analyzer. Locate the EXCLUDED_FILES
constant in test_http_auth.py (symbol EXCLUDED_FILES) and change its definition
from {"a", "b", ...} to frozenset({...}) (or use frozenset([...])) so the value
is immutable; ensure no test mutates it anywhere else.
cli/config/helpers.py (1)

162-167: 💤 Low value

Consider using get_token_auth_headers directly.

Since get_runtimes_from_api always uses token authentication, you could simplify by calling get_token_auth_headers(access_token) instead of constructing a ProfileConfigModel just to pass to get_auth_headers.

♻️ Proposed simplification
 def get_runtimes_from_api(access_token: str) -> list[dict]:
-    headers = get_auth_headers(
-        ProfileConfigModel(
-            auth_method=AuthHeaderTypeEnum.TOKEN,
-            access_token=access_token,
-        )
-    )
+    from cli.commons.http_auth import get_token_auth_headers
+    headers = get_token_auth_headers(access_token)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/config/helpers.py` around lines 162 - 167, The code in
get_runtimes_from_api constructs a ProfileConfigModel with
AuthHeaderTypeEnum.TOKEN only to call get_auth_headers; replace that by calling
get_token_auth_headers(access_token) directly to simplify and remove the
unnecessary ProfileConfigModel construction (references: get_runtimes_from_api,
get_auth_headers, get_token_auth_headers, ProfileConfigModel,
AuthHeaderTypeEnum.TOKEN, access_token).
cli/auth/commands.py (1)

55-67: ⚡ Quick win

Consider narrowing exception handling for clarity.

The bare except Exception: on line 66 is functionally correct (JWT parsing is non-critical; fallback is safe), but narrowing to specific expected exceptions would improve readability and catch genuine bugs.

♻️ Proposed refinement
         claims = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8"))
         return claims.get("email") or claims.get("preferred_username") or claims.get("sub") or fallback
-    except Exception:
+    except (ValueError, json.JSONDecodeError, UnicodeDecodeError, IndexError, KeyError):
         return fallback
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/auth/commands.py` around lines 55 - 67, The broad except in
_extract_user_label should be narrowed to the specific errors expected during
JWT parsing (e.g., IndexError, AttributeError, ValueError, json.JSONDecodeError,
and binascii.Error) so genuine bugs aren't swallowed; update the except clause
on the block that reads token_set.access_token, splits the JWT, base64-decodes
the payload, and parses JSON to catch and handle only those exceptions and still
return fallback on failures.
cli/auth/oauth_client.py (1)

99-107: ⚡ Quick win

Consider defensive handling for missing access_token in successful response.

Line 102 directly accesses data["access_token"], which will raise KeyError if the OAuth server returns a 200 OK response without the required field. While this indicates a broken OAuth server (protocol violation), wrapping this in a try-except and raising a TokenExchangeError would provide a clearer error message.

🛡️ Proposed defensive handling
     data = response.json()
     expires_in = int(data.get("expires_in", 0))
+    try:
-    return TokenSet(
-        access_token=data["access_token"],
-        refresh_token=data.get("refresh_token", ""),
-        token_type=data.get("token_type", "Bearer"),
-        expires_at=int(time.time()) + expires_in,
-        scope=data.get("scope", ""),
-    )
+        return TokenSet(
+            access_token=data["access_token"],
+            refresh_token=data.get("refresh_token", ""),
+            token_type=data.get("token_type", "Bearer"),
+            expires_at=int(time.time()) + expires_in,
+            scope=data.get("scope", ""),
+        )
+    except KeyError as exc:
+        raise TokenExchangeError(detail=f"Server response missing required field: {exc}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/auth/oauth_client.py` around lines 99 - 107, The code in oauth_client.py
that builds and returns TokenSet directly indexes data["access_token"], which
can raise KeyError on malformed but 200 responses; update the token-exchange
logic (the function that returns TokenSet) to defensively validate that
"access_token" exists (e.g., check "access_token" in data or try/except
KeyError) and if missing raise a TokenExchangeError with a clear message
including the response body/status for debugging instead of letting a raw
KeyError propagate; ensure refresh_token, token_type, expires_at and scope are
still populated as before when access_token is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/auth/oauth_client.py`:
- Around line 95-96: The error check in oauth_client.py is too broad because it
raises UnknownOAuthClientError whenever "client" appears in detail; narrow this
by matching specific OAuth error codes/phrases (e.g., "invalid_client",
"unauthorized_client", or exact error_code fields) or use a precise
regex/whole-word match for known client-related messages rather than any
substring "client". Update the conditional that currently inspects the variable
detail (the branch that raises UnknownOAuthClientError) to only trigger for
those explicit patterns or mapped error_code values so generic messages like
"client-side validation failed" won't be misclassified.

In `@pyproject.toml`:
- Line 72: The Ruff config's target-version ("target-version = \"py314\"") is
incorrect for your declared Python range; update the target-version to "py39" so
it matches tool.poetry.dependencies' python = ">=3.9,<3.15" and avoids
suggesting unreleased/unsupported syntax—edit the target-version line in
pyproject.toml accordingly.

---

Nitpick comments:
In `@cli/auth/commands.py`:
- Around line 55-67: The broad except in _extract_user_label should be narrowed
to the specific errors expected during JWT parsing (e.g., IndexError,
AttributeError, ValueError, json.JSONDecodeError, and binascii.Error) so genuine
bugs aren't swallowed; update the except clause on the block that reads
token_set.access_token, splits the JWT, base64-decodes the payload, and parses
JSON to catch and handle only those exceptions and still return fallback on
failures.

In `@cli/auth/oauth_client.py`:
- Around line 99-107: The code in oauth_client.py that builds and returns
TokenSet directly indexes data["access_token"], which can raise KeyError on
malformed but 200 responses; update the token-exchange logic (the function that
returns TokenSet) to defensively validate that "access_token" exists (e.g.,
check "access_token" in data or try/except KeyError) and if missing raise a
TokenExchangeError with a clear message including the response body/status for
debugging instead of letting a raw KeyError propagate; ensure refresh_token,
token_type, expires_at and scope are still populated as before when access_token
is present.

In `@cli/commons/tests/test_http_auth.py`:
- Around line 61-65: EXCLUDED_FILES is defined as a mutable set literal; replace
it with an immutable frozenset to make the constant explicit and silence the
static analyzer. Locate the EXCLUDED_FILES constant in test_http_auth.py (symbol
EXCLUDED_FILES) and change its definition from {"a", "b", ...} to
frozenset({...}) (or use frozenset([...])) so the value is immutable; ensure no
test mutates it anywhere else.

In `@cli/config/helpers.py`:
- Around line 162-167: The code in get_runtimes_from_api constructs a
ProfileConfigModel with AuthHeaderTypeEnum.TOKEN only to call get_auth_headers;
replace that by calling get_token_auth_headers(access_token) directly to
simplify and remove the unnecessary ProfileConfigModel construction (references:
get_runtimes_from_api, get_auth_headers, get_token_auth_headers,
ProfileConfigModel, AuthHeaderTypeEnum.TOKEN, access_token).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba009eb1-75d2-449d-90f2-e4a0a1fad4ec

📥 Commits

Reviewing files that changed from the base of the PR and between cfe4861 and 094362c.

📒 Files selected for processing (18)
  • cli/auth/__init__.py
  • cli/auth/commands.py
  • cli/auth/loopback_server.py
  • cli/auth/oauth_client.py
  • cli/auth/tests/__init__.py
  • cli/auth/tests/test_commands.py
  • cli/auth/tests/test_loopback_server.py
  • cli/auth/tests/test_oauth_client.py
  • cli/commons/exceptions.py
  • cli/commons/http_auth.py
  • cli/commons/tests/test_http_auth.py
  • cli/commons/utils.py
  • cli/config/helpers.py
  • cli/config/models.py
  • cli/config/tests/test_oauth_profile_model.py
  • cli/main.py
  • cli/settings.py
  • pyproject.toml

Comment thread cli/auth/oauth_client.py Outdated
Comment thread pyproject.toml Outdated

[tool.ruff]
target-version = "py313"
target-version = "py314"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ruff target-version does not match supported Python versions.

The target-version is set to "py314" (Python 3.14), but tool.poetry.dependencies declares support for python = ">=3.9,<3.15". Python 3.14 has not been released yet (as of March 2025), and targeting an unreleased version may cause Ruff to suggest syntax or features incompatible with the actual minimum supported version (3.9).

Set target-version = "py39" to align with the minimum supported Python version.

🔧 Proposed fix
-target-version = "py314"
+target-version = "py39"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
target-version = "py314"
target-version = "py39"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` at line 72, The Ruff config's target-version ("target-version
= \"py314\"") is incorrect for your declared Python range; update the
target-version to "py39" so it matches tool.poetry.dependencies' python =
">=3.9,<3.15" and avoids suggesting unreleased/unsupported syntax—edit the
target-version line in pyproject.toml accordingly.

woakas added 4 commits May 12, 2026 17:09
Permite que el comando login funcione contra entornos alternativos
sin tener que pre-configurar un profile completo.

- --port / UBIDOTS_OAUTH_LOOPBACK_PORT: override del loopback (default 53682).
  Útil si 53682 está ocupado o si el customer self-hosted registró otro
  redirect_uri en su core. El AS sigue siendo source of truth — DOT exige
  exact-match del redirect_uri, así que el port custom debe estar registrado
  en la OAuth Application.

- --api-domain / -a / UBIDOTS_API_DOMAIN: override del host del Authorization
  Server (ej. https://cs.ubidots.site). El api_domain elegido se persiste
  en el profile YAML al guardar los tokens.

- Mensaje de "port in use" ahora es accionable: incluye el comando lsof
  para encontrar el proceso, cómo usar --port, y la nota de registro del
  redirect_uri.

7 tests nuevos cubren la cascada de resolución de ambos flags + el mensaje
diagnóstico de port-in-use.
- oauth_client.py: narrow the UnknownOAuthClientError mapping to the
  exact OAuth2 code `invalid_client` (RFC 6749 §5.2). The previous
  `"client" in detail.lower()` would misclassify generic errors like
  "Client-side validation failed". Added a regression test.

- pyproject.toml: revert ruff target-version from py314 → py313 to
  match the repo's pre-existing target (and the actual minimum Python
  where this CLI's code patterns work in practice). The mismatch
  between python = ">=3.9" in dependencies and py313 target is
  pre-existing repo debt; out of scope for this PR.
…irmation

Bugs encontrados al validar el flow con múltiples profiles:

- BUG 1 — Cuando `--profile` no se pasa, el comando leía el profile
  activo correctamente pero guardaba los tokens siempre en
  `default.yaml`. Ahora resuelve el active profile real desde
  `config.yaml::profile` y persiste en ese archivo. Cubierto por
  `test_no_profile_flag_writes_to_active_profile_not_default`.

- BUG 2 — `--profile X` con X inexistente fallaba con "File not found"
  porque `get_configuration` hace exit cuando el YAML no existe.
  Ahora `_resolve_active_config` detecta el profile inexistente y
  arranca con un `ProfileConfigModel` vacío; al final el archivo se
  crea en `<profiles_path>/X.yaml`. Cubierto por
  `test_explicit_profile_flag_creates_new_profile_if_missing`.

Mejoras UX:

- Mensaje al inicio: `Logging into profile '<name>' at <api_domain>
  (client_id=...)` para que sea obvio qué profile va a tocar antes
  de abrir el browser.

- Si el profile target ya tiene sesión OAuth activa (auth_method=OAUTH2,
  access_token, refresh_token, expires_at > now), pedir confirmación
  con el email del usuario actual antes de sobrescribir. Skip con
  `--yes` / `-y` para CI/scripts.

- Mensaje de éxito ahora incluye el profile: `Login successful as
  <email> (profile: <name>)`.

5 tests nuevos; suite total 56 nuevos en verde.
DevOps registrará la OAuth Application en core con
`client_id=ubidots-cli` (decisión ya documentada en el design del
epic 2517). Hardcodear el default acá deja `ubidots login` listo
para usar sin flags ni env vars — mismo UX que `gh auth login`,
`gcloud auth login`, etc.

Override sigue disponible vía:
- `--client-id <id>` (mayor precedencia, override por invocación)
- `UBIDOTS_OAUTH_CLIENT_ID=<id>` (env var, útil para self-hosted)
- `oauth_client_id` ya guardado en el profile (reuso post-login)

Tests no requieren cambios: los casos que validan el missing-client
escenario hacen `monkeypatch.setattr(settings.OAUTH, "DEFAULT_CLIENT_ID", "")`
explícito.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant