flwr-nightly 1.23.0.dev20251007__py3-none-any.whl → 1.23.0.dev20251009__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.
Files changed (38) hide show
  1. flwr/cli/auth_plugin/__init__.py +7 -3
  2. flwr/cli/log.py +2 -2
  3. flwr/cli/login/login.py +4 -13
  4. flwr/cli/ls.py +2 -2
  5. flwr/cli/pull.py +2 -2
  6. flwr/cli/run/run.py +2 -2
  7. flwr/cli/stop.py +2 -2
  8. flwr/cli/supernode/create.py +137 -11
  9. flwr/cli/supernode/delete.py +88 -10
  10. flwr/cli/supernode/ls.py +2 -2
  11. flwr/cli/utils.py +65 -55
  12. flwr/client/grpc_rere_client/connection.py +6 -4
  13. flwr/client/grpc_rere_client/{client_interceptor.py → node_auth_client_interceptor.py} +2 -2
  14. flwr/client/rest_client/connection.py +7 -1
  15. flwr/common/constant.py +13 -0
  16. flwr/proto/control_pb2.py +1 -1
  17. flwr/proto/control_pb2.pyi +2 -2
  18. flwr/proto/fleet_pb2.py +22 -22
  19. flwr/proto/fleet_pb2.pyi +4 -1
  20. flwr/proto/node_pb2.py +2 -2
  21. flwr/proto/node_pb2.pyi +4 -1
  22. flwr/server/app.py +32 -31
  23. flwr/server/superlink/fleet/grpc_rere/fleet_servicer.py +8 -4
  24. flwr/server/superlink/fleet/grpc_rere/{server_interceptor.py → node_auth_server_interceptor.py} +18 -37
  25. flwr/server/superlink/fleet/message_handler/message_handler.py +5 -3
  26. flwr/server/superlink/fleet/vce/vce_api.py +10 -1
  27. flwr/server/superlink/linkstate/in_memory_linkstate.py +52 -54
  28. flwr/server/superlink/linkstate/linkstate.py +20 -10
  29. flwr/server/superlink/linkstate/sqlite_linkstate.py +54 -61
  30. flwr/server/utils/validator.py +2 -3
  31. flwr/supercore/primitives/asymmetric.py +8 -0
  32. flwr/superlink/auth_plugin/__init__.py +29 -0
  33. flwr/superlink/servicer/control/control_grpc.py +9 -7
  34. flwr/superlink/servicer/control/control_servicer.py +89 -48
  35. {flwr_nightly-1.23.0.dev20251007.dist-info → flwr_nightly-1.23.0.dev20251009.dist-info}/METADATA +1 -1
  36. {flwr_nightly-1.23.0.dev20251007.dist-info → flwr_nightly-1.23.0.dev20251009.dist-info}/RECORD +38 -38
  37. {flwr_nightly-1.23.0.dev20251007.dist-info → flwr_nightly-1.23.0.dev20251009.dist-info}/WHEEL +0 -0
  38. {flwr_nightly-1.23.0.dev20251007.dist-info → flwr_nightly-1.23.0.dev20251009.dist-info}/entry_points.txt +0 -0
@@ -22,9 +22,13 @@ from .noop_auth_plugin import NoOpCliAuthPlugin
22
22
  from .oidc_cli_plugin import OidcCliPlugin
23
23
 
24
24
 
25
- def get_cli_auth_plugins() -> dict[str, type[CliAuthPlugin]]:
25
+ def get_cli_plugin_class(authn_type: str) -> type[CliAuthPlugin]:
26
26
  """Return all CLI authentication plugins."""
27
- return {AuthnType.NOOP: NoOpCliAuthPlugin, AuthnType.OIDC: OidcCliPlugin}
27
+ if authn_type == AuthnType.NOOP:
28
+ return NoOpCliAuthPlugin
29
+ if authn_type == AuthnType.OIDC:
30
+ return OidcCliPlugin
31
+ raise ValueError(f"Unsupported authentication type: {authn_type}")
28
32
 
29
33
 
30
34
  __all__ = [
@@ -32,5 +36,5 @@ __all__ = [
32
36
  "LoginError",
33
37
  "NoOpCliAuthPlugin",
34
38
  "OidcCliPlugin",
35
- "get_cli_auth_plugins",
39
+ "get_cli_plugin_class",
36
40
  ]
flwr/cli/log.py CHANGED
@@ -35,7 +35,7 @@ from flwr.common.logger import log as logger
35
35
  from flwr.proto.control_pb2 import StreamLogsRequest # pylint: disable=E0611
36
36
  from flwr.proto.control_pb2_grpc import ControlStub
37
37
 
38
- from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
38
+ from .utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
39
39
 
40
40
 
41
41
  class AllLogsRetrieved(BaseException):
@@ -186,7 +186,7 @@ def _log_with_control_api(
186
186
  run_id: int,
187
187
  stream: bool,
188
188
  ) -> None:
189
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
189
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
190
190
  channel = init_channel(app, federation_config, auth_plugin)
191
191
 
192
192
  if stream:
flwr/cli/login/login.py CHANGED
@@ -20,7 +20,7 @@ from typing import Annotated, Optional
20
20
 
21
21
  import typer
22
22
 
23
- from flwr.cli.auth_plugin import LoginError
23
+ from flwr.cli.auth_plugin import LoginError, NoOpCliAuthPlugin
24
24
  from flwr.cli.config_utils import (
25
25
  exit_if_no_address,
26
26
  get_insecure_flag,
@@ -40,7 +40,7 @@ from ..utils import (
40
40
  account_auth_enabled,
41
41
  flwr_cli_grpc_exc_handler,
42
42
  init_channel,
43
- try_obtain_cli_auth_plugin,
43
+ load_cli_auth_plugin,
44
44
  )
45
45
 
46
46
 
@@ -95,7 +95,7 @@ def login( # pylint: disable=R0914
95
95
  )
96
96
  raise typer.Exit(code=1)
97
97
 
98
- channel = init_channel(app, federation_config, None)
98
+ channel = init_channel(app, federation_config, NoOpCliAuthPlugin(Path()))
99
99
  stub = ControlStub(channel)
100
100
 
101
101
  login_request = GetLoginDetailsRequest()
@@ -104,16 +104,7 @@ def login( # pylint: disable=R0914
104
104
 
105
105
  # Get the auth plugin
106
106
  authn_type = login_response.authn_type
107
- auth_plugin = try_obtain_cli_auth_plugin(
108
- app, federation, federation_config, authn_type
109
- )
110
- if auth_plugin is None:
111
- typer.secho(
112
- f'❌ Authentication type "{authn_type}" not found',
113
- fg=typer.colors.RED,
114
- bold=True,
115
- )
116
- raise typer.Exit(code=1)
107
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config, authn_type)
117
108
 
118
109
  # Login
119
110
  details = AccountAuthLoginDetails(
flwr/cli/ls.py CHANGED
@@ -44,7 +44,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
44
44
  )
45
45
  from flwr.proto.control_pb2_grpc import ControlStub
46
46
 
47
- from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
47
+ from .utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
48
48
 
49
49
  _RunListType = tuple[int, str, str, str, str, str, str, str, str]
50
50
 
@@ -127,7 +127,7 @@ def ls( # pylint: disable=too-many-locals, too-many-branches, R0913, R0917
127
127
  raise ValueError(
128
128
  "The options '--runs' and '--run-id' are mutually exclusive."
129
129
  )
130
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
130
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
131
131
  channel = init_channel(app, federation_config, auth_plugin)
132
132
  stub = ControlStub(channel)
133
133
 
flwr/cli/pull.py CHANGED
@@ -34,7 +34,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
34
34
  )
35
35
  from flwr.proto.control_pb2_grpc import ControlStub
36
36
 
37
- from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
37
+ from .utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
38
38
 
39
39
 
40
40
  def pull( # pylint: disable=R0914
@@ -74,7 +74,7 @@ def pull( # pylint: disable=R0914
74
74
  channel = None
75
75
  try:
76
76
 
77
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
77
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
78
78
  channel = init_channel(app, federation_config, auth_plugin)
79
79
  stub = ControlStub(channel)
80
80
  with flwr_cli_grpc_exc_handler():
flwr/cli/run/run.py CHANGED
@@ -45,7 +45,7 @@ from flwr.proto.control_pb2 import StartRunRequest # pylint: disable=E0611
45
45
  from flwr.proto.control_pb2_grpc import ControlStub
46
46
 
47
47
  from ..log import start_stream
48
- from ..utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
48
+ from ..utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
49
49
 
50
50
  CONN_REFRESH_PERIOD = 60 # Connection refresh period for log streaming (seconds)
51
51
 
@@ -148,7 +148,7 @@ def _run_with_control_api(
148
148
  ) -> None:
149
149
  channel = None
150
150
  try:
151
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
151
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
152
152
  channel = init_channel(app, federation_config, auth_plugin)
153
153
  stub = ControlStub(channel)
154
154
 
flwr/cli/stop.py CHANGED
@@ -38,7 +38,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
38
38
  )
39
39
  from flwr.proto.control_pb2_grpc import ControlStub
40
40
 
41
- from .utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
41
+ from .utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
42
42
 
43
43
 
44
44
  def stop( # pylint: disable=R0914
@@ -89,7 +89,7 @@ def stop( # pylint: disable=R0914
89
89
  exit_if_no_address(federation_config, "stop")
90
90
  channel = None
91
91
  try:
92
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
92
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
93
93
  channel = init_channel(app, federation_config, auth_plugin)
94
94
  stub = ControlStub(channel) # pylint: disable=unused-variable # noqa: F841
95
95
 
@@ -14,10 +14,16 @@
14
14
  # ==============================================================================
15
15
  """Flower command line interface `supernode create` command."""
16
16
 
17
+
18
+ import io
19
+ import json
17
20
  from pathlib import Path
18
21
  from typing import Annotated, Optional
19
22
 
20
23
  import typer
24
+ from cryptography.hazmat.primitives import serialization
25
+ from cryptography.hazmat.primitives.asymmetric import ec
26
+ from rich.console import Console
21
27
 
22
28
  from flwr.cli.config_utils import (
23
29
  exit_if_no_address,
@@ -25,14 +31,23 @@ from flwr.cli.config_utils import (
25
31
  process_loaded_project_config,
26
32
  validate_federation_in_project_config,
27
33
  )
28
- from flwr.common.constant import FAB_CONFIG_FILE
34
+ from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat
35
+ from flwr.common.logger import print_json_error, redirect_output, restore_output
36
+ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
37
+ CreateNodeCliRequest,
38
+ CreateNodeCliResponse,
39
+ )
40
+ from flwr.proto.control_pb2_grpc import ControlStub
41
+ from flwr.supercore.primitives.asymmetric import public_key_to_bytes, uses_nist_ec_curve
42
+
43
+ from ..utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
29
44
 
30
45
 
31
46
  def create( # pylint: disable=R0914
32
47
  public_key: Annotated[
33
48
  Path,
34
49
  typer.Argument(
35
- help="Path to the public key file.",
50
+ help="Path to a P-384 (or any other NIST EC curve) public key file.",
36
51
  ),
37
52
  ],
38
53
  app: Annotated[
@@ -43,16 +58,127 @@ def create( # pylint: disable=R0914
43
58
  Optional[str],
44
59
  typer.Argument(help="Name of the federation"),
45
60
  ] = None,
61
+ output_format: Annotated[
62
+ str,
63
+ typer.Option(
64
+ "--format",
65
+ case_sensitive=False,
66
+ help="Format output using 'default' view or 'json'",
67
+ ),
68
+ ] = CliOutputFormat.DEFAULT,
46
69
  ) -> None:
47
70
  """Add a SuperNode to the federation."""
48
- typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
71
+ suppress_output = output_format == CliOutputFormat.JSON
72
+ captured_output = io.StringIO()
73
+
74
+ # Load public key
75
+ public_key_path = Path(public_key)
76
+ public_key_bytes = try_load_public_key(public_key_path)
77
+
78
+ try:
79
+ if suppress_output:
80
+ redirect_output(captured_output)
81
+
82
+ # Load and validate federation config
83
+ typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
84
+
85
+ pyproject_path = app / FAB_CONFIG_FILE if app else None
86
+ config, errors, warnings = load_and_validate(path=pyproject_path)
87
+ config = process_loaded_project_config(config, errors, warnings)
88
+ federation, federation_config = validate_federation_in_project_config(
89
+ federation, config
90
+ )
91
+ exit_if_no_address(federation_config, "supernode create")
92
+
93
+ channel = None
94
+ try:
95
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
96
+ channel = init_channel(app, federation_config, auth_plugin)
97
+ stub = ControlStub(channel) # pylint: disable=unused-variable # noqa: F841
98
+
99
+ _create_node(
100
+ stub=stub, public_key=public_key_bytes, output_format=output_format
101
+ )
102
+
103
+ except ValueError as err:
104
+ typer.secho(
105
+ f"❌ {err}",
106
+ fg=typer.colors.RED,
107
+ bold=True,
108
+ )
109
+ raise typer.Exit(code=1) from err
110
+ finally:
111
+ if channel:
112
+ channel.close()
113
+
114
+ except (typer.Exit, Exception) as err: # pylint: disable=broad-except
115
+ if suppress_output:
116
+ restore_output()
117
+ e_message = captured_output.getvalue()
118
+ print_json_error(e_message, err)
119
+ else:
120
+ typer.secho(
121
+ f"{err}",
122
+ fg=typer.colors.RED,
123
+ bold=True,
124
+ )
125
+ finally:
126
+ if suppress_output:
127
+ restore_output()
128
+ captured_output.close()
129
+
130
+
131
+ def _create_node(stub: ControlStub, public_key: bytes, output_format: str) -> None:
132
+ """Create a node."""
133
+ with flwr_cli_grpc_exc_handler():
134
+ response: CreateNodeCliResponse = stub.CreateNodeCli(
135
+ request=CreateNodeCliRequest(public_key=public_key)
136
+ )
137
+ if response.node_id:
138
+ typer.secho(
139
+ f"✅ Node {response.node_id} created successfully.", fg=typer.colors.GREEN
140
+ )
141
+ if output_format == CliOutputFormat.JSON:
142
+ run_output = json.dumps(
143
+ {
144
+ "success": True,
145
+ "node-id": response.node_id,
146
+ }
147
+ )
148
+ restore_output()
149
+ Console().print_json(run_output)
150
+ else:
151
+ typer.secho("❌ Node couldn't be created.", fg=typer.colors.RED)
152
+
153
+
154
+ def try_load_public_key(public_key_path: Path) -> bytes:
155
+ """Try to load a public key from a file."""
156
+ if not public_key_path.exists():
157
+ typer.secho(
158
+ f"❌ Public key file '{public_key_path}' does not exist.",
159
+ fg=typer.colors.RED,
160
+ bold=True,
161
+ )
162
+ raise typer.Exit(code=1)
163
+
164
+ with open(public_key_path, "rb") as key_file:
165
+ try:
166
+ public_key = serialization.load_pem_public_key(key_file.read())
167
+
168
+ if not isinstance(public_key, ec.EllipticCurvePublicKey):
169
+ raise ValueError(f"Not an EC public key, got {type(public_key)}")
49
170
 
50
- pyproject_path = app / FAB_CONFIG_FILE if app else None
51
- config, errors, warnings = load_and_validate(path=pyproject_path)
52
- config = process_loaded_project_config(config, errors, warnings)
53
- federation, federation_config = validate_federation_in_project_config(
54
- federation, config
55
- )
56
- exit_if_no_address(federation_config, "supernode add")
171
+ # Verify it's one of the approved NIST curves
172
+ if not uses_nist_ec_curve(public_key):
173
+ raise ValueError(
174
+ f"EC curve {public_key.curve.name} is not an approved NIST curve"
175
+ )
57
176
 
58
- _ = public_key
177
+ except ValueError as err:
178
+ typer.secho(
179
+ f"❌ Unable to load public key from '{public_key_path}': {err}",
180
+ fg=typer.colors.RED,
181
+ bold=True,
182
+ )
183
+ raise typer.Exit(code=1) from err
184
+ return public_key_to_bytes(public_key)
@@ -14,10 +14,14 @@
14
14
  # ==============================================================================
15
15
  """Flower command line interface `supernode delete` command."""
16
16
 
17
+
18
+ import io
19
+ import json
17
20
  from pathlib import Path
18
21
  from typing import Annotated, Optional
19
22
 
20
23
  import typer
24
+ from rich.console import Console
21
25
 
22
26
  from flwr.cli.config_utils import (
23
27
  exit_if_no_address,
@@ -25,7 +29,12 @@ from flwr.cli.config_utils import (
25
29
  process_loaded_project_config,
26
30
  validate_federation_in_project_config,
27
31
  )
28
- from flwr.common.constant import FAB_CONFIG_FILE
32
+ from flwr.common.constant import FAB_CONFIG_FILE, CliOutputFormat
33
+ from flwr.common.logger import print_json_error, redirect_output, restore_output
34
+ from flwr.proto.control_pb2 import DeleteNodeCliRequest # pylint: disable=E0611
35
+ from flwr.proto.control_pb2_grpc import ControlStub
36
+
37
+ from ..utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
29
38
 
30
39
 
31
40
  def delete( # pylint: disable=R0914
@@ -43,16 +52,85 @@ def delete( # pylint: disable=R0914
43
52
  Optional[str],
44
53
  typer.Argument(help="Name of the federation"),
45
54
  ] = None,
55
+ output_format: Annotated[
56
+ str,
57
+ typer.Option(
58
+ "--format",
59
+ case_sensitive=False,
60
+ help="Format output using 'default' view or 'json'",
61
+ ),
62
+ ] = CliOutputFormat.DEFAULT,
46
63
  ) -> None:
47
64
  """Remove a SuperNode from the federation."""
48
- typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
65
+ suppress_output = output_format == CliOutputFormat.JSON
66
+ captured_output = io.StringIO()
67
+
68
+ try:
69
+ if suppress_output:
70
+ redirect_output(captured_output)
71
+
72
+ # Load and validate federation config
73
+ typer.secho("Loading project configuration... ", fg=typer.colors.BLUE)
74
+
75
+ pyproject_path = app / FAB_CONFIG_FILE if app else None
76
+ config, errors, warnings = load_and_validate(path=pyproject_path)
77
+ config = process_loaded_project_config(config, errors, warnings)
78
+ federation, federation_config = validate_federation_in_project_config(
79
+ federation, config
80
+ )
81
+ exit_if_no_address(federation_config, "supernode remove")
49
82
 
50
- pyproject_path = app / FAB_CONFIG_FILE if app else None
51
- config, errors, warnings = load_and_validate(path=pyproject_path)
52
- config = process_loaded_project_config(config, errors, warnings)
53
- federation, federation_config = validate_federation_in_project_config(
54
- federation, config
55
- )
56
- exit_if_no_address(federation_config, "supernode rm")
83
+ channel = None
84
+ try:
85
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
86
+ channel = init_channel(app, federation_config, auth_plugin)
87
+ stub = ControlStub(channel) # pylint: disable=unused-variable # noqa: F841
57
88
 
58
- _ = node_id
89
+ _delete_node(stub=stub, node_id=node_id, output_format=output_format)
90
+
91
+ except ValueError as err:
92
+ typer.secho(
93
+ f"❌ {err}",
94
+ fg=typer.colors.RED,
95
+ bold=True,
96
+ )
97
+ raise typer.Exit(code=1) from err
98
+ finally:
99
+ if channel:
100
+ channel.close()
101
+
102
+ except (typer.Exit, Exception) as err: # pylint: disable=broad-except
103
+ if suppress_output:
104
+ restore_output()
105
+ e_message = captured_output.getvalue()
106
+ print_json_error(e_message, err)
107
+ else:
108
+ typer.secho(
109
+ f"{err}",
110
+ fg=typer.colors.RED,
111
+ bold=True,
112
+ )
113
+ finally:
114
+ if suppress_output:
115
+ restore_output()
116
+ captured_output.close()
117
+
118
+
119
+ def _delete_node(
120
+ stub: ControlStub,
121
+ node_id: int,
122
+ output_format: str,
123
+ ) -> None:
124
+ """Delete a SuperNode from the federation."""
125
+ with flwr_cli_grpc_exc_handler():
126
+ stub.DeleteNodeCli(request=DeleteNodeCliRequest(node_id=node_id))
127
+ typer.secho(f"✅ SuperNode {node_id} deleted successfully.", fg=typer.colors.GREEN)
128
+ if output_format == CliOutputFormat.JSON:
129
+ run_output = json.dumps(
130
+ {
131
+ "success": True,
132
+ "node-id": node_id,
133
+ }
134
+ )
135
+ restore_output()
136
+ Console().print_json(run_output)
flwr/cli/supernode/ls.py CHANGED
@@ -42,7 +42,7 @@ from flwr.proto.control_pb2 import ( # pylint: disable=E0611
42
42
  from flwr.proto.control_pb2_grpc import ControlStub
43
43
  from flwr.proto.node_pb2 import NodeInfo # pylint: disable=E0611
44
44
 
45
- from ..utils import flwr_cli_grpc_exc_handler, init_channel, try_obtain_cli_auth_plugin
45
+ from ..utils import flwr_cli_grpc_exc_handler, init_channel, load_cli_auth_plugin
46
46
 
47
47
  _NodeListType = tuple[int, str, str, str, str, str, str, str]
48
48
 
@@ -94,7 +94,7 @@ def ls( # pylint: disable=R0914
94
94
  exit_if_no_address(federation_config, f"supernode {command_name}")
95
95
  channel = None
96
96
  try:
97
- auth_plugin = try_obtain_cli_auth_plugin(app, federation, federation_config)
97
+ auth_plugin = load_cli_auth_plugin(app, federation, federation_config)
98
98
  channel = init_channel(app, federation_config, auth_plugin)
99
99
  stub = ControlStub(channel)
100
100
  typer.echo("📄 Listing all nodes...")
flwr/cli/utils.py CHANGED
@@ -32,8 +32,12 @@ from flwr.common.constant import (
32
32
  FLWR_DIR,
33
33
  NO_ACCOUNT_AUTH_MESSAGE,
34
34
  NO_ARTIFACT_PROVIDER_MESSAGE,
35
+ NODE_NOT_FOUND_MESSAGE,
36
+ PUBLIC_KEY_ALREADY_IN_USE_MESSAGE,
37
+ PUBLIC_KEY_NOT_VALID,
35
38
  PULL_UNFINISHED_RUN_MESSAGE,
36
39
  RUN_ID_NOT_FOUND_MESSAGE,
40
+ AuthnType,
37
41
  )
38
42
  from flwr.common.grpc import (
39
43
  GRPC_MAX_MESSAGE_LENGTH,
@@ -41,7 +45,7 @@ from flwr.common.grpc import (
41
45
  on_channel_state_change,
42
46
  )
43
47
 
44
- from .auth_plugin import CliAuthPlugin, get_cli_auth_plugins
48
+ from .auth_plugin import CliAuthPlugin, get_cli_plugin_class
45
49
  from .cli_account_auth_interceptor import CliAccountAuthInterceptor
46
50
  from .config_utils import validate_certificate_in_federation_config
47
51
 
@@ -230,71 +234,54 @@ def account_auth_enabled(federation_config: dict[str, Any]) -> bool:
230
234
  return enabled
231
235
 
232
236
 
233
- def try_obtain_cli_auth_plugin(
237
+ def retrieve_authn_type(config_path: Path) -> str:
238
+ """Retrieve the auth type from the config file or return NOOP if not found."""
239
+ try:
240
+ with config_path.open("r", encoding="utf-8") as file:
241
+ json_file = json.load(file)
242
+ authn_type: str = json_file[AUTHN_TYPE_JSON_KEY]
243
+ return authn_type
244
+ except (FileNotFoundError, KeyError):
245
+ return AuthnType.NOOP
246
+
247
+
248
+ def load_cli_auth_plugin(
234
249
  root_dir: Path,
235
250
  federation: str,
236
251
  federation_config: dict[str, Any],
237
252
  authn_type: Optional[str] = None,
238
- ) -> Optional[CliAuthPlugin]:
253
+ ) -> CliAuthPlugin:
239
254
  """Load the CLI-side account auth plugin for the given authn type."""
240
- # Check if account auth is enabled
241
- if not account_auth_enabled(federation_config):
242
- return None
243
-
255
+ # Find the path to the account auth config file
244
256
  config_path = get_account_auth_config_path(root_dir, federation)
245
257
 
246
- # Get the authn type from the config if not provided
247
- # authn_type will be None for all CLI commands except login
258
+ # Determine the auth type if not provided
259
+ # Only `flwr login` command can provide `authn_type` explicitly, as it can query the
260
+ # SuperLink for the auth type.
248
261
  if authn_type is None:
249
- try:
250
- with config_path.open("r", encoding="utf-8") as file:
251
- json_file = json.load(file)
252
- authn_type = json_file[AUTHN_TYPE_JSON_KEY]
253
- except (FileNotFoundError, KeyError):
254
- typer.secho(
255
- "❌ Missing or invalid credentials for account authentication. "
256
- "Please run `flwr login` to authenticate.",
257
- fg=typer.colors.RED,
258
- bold=True,
259
- )
260
- raise typer.Exit(code=1) from None
262
+ authn_type = AuthnType.NOOP
263
+ if account_auth_enabled(federation_config):
264
+ authn_type = retrieve_authn_type(config_path)
261
265
 
262
266
  # Retrieve auth plugin class and instantiate it
263
267
  try:
264
- all_plugins: dict[str, type[CliAuthPlugin]] = get_cli_auth_plugins()
265
- auth_plugin_class = all_plugins[authn_type]
268
+ auth_plugin_class = get_cli_plugin_class(authn_type)
266
269
  return auth_plugin_class(config_path)
267
- except KeyError:
270
+ except ValueError:
268
271
  typer.echo(f"❌ Unknown account authentication type: {authn_type}")
269
272
  raise typer.Exit(code=1) from None
270
- except ImportError:
271
- typer.echo("❌ No authentication plugins are currently supported.")
272
- raise typer.Exit(code=1) from None
273
273
 
274
274
 
275
275
  def init_channel(
276
- app: Path, federation_config: dict[str, Any], auth_plugin: Optional[CliAuthPlugin]
276
+ app: Path, federation_config: dict[str, Any], auth_plugin: CliAuthPlugin
277
277
  ) -> grpc.Channel:
278
278
  """Initialize gRPC channel to the Control API."""
279
279
  insecure, root_certificates_bytes = validate_certificate_in_federation_config(
280
280
  app, federation_config
281
281
  )
282
282
 
283
- # Initialize the CLI-side account auth interceptor
284
- interceptors: list[grpc.UnaryUnaryClientInterceptor] = []
285
- if auth_plugin is not None:
286
- # Check if TLS is enabled. If not, raise an error
287
- if insecure:
288
- typer.secho(
289
- "❌ Account authentication requires TLS to be enabled. "
290
- "Remove `insecure = true` from the federation configuration.",
291
- fg=typer.colors.RED,
292
- bold=True,
293
- )
294
- raise typer.Exit(code=1)
295
-
296
- auth_plugin.load_tokens()
297
- interceptors.append(CliAccountAuthInterceptor(auth_plugin))
283
+ # Load tokens
284
+ auth_plugin.load_tokens()
298
285
 
299
286
  # Create the gRPC channel
300
287
  channel = create_channel(
@@ -302,14 +289,14 @@ def init_channel(
302
289
  insecure=insecure,
303
290
  root_certificates=root_certificates_bytes,
304
291
  max_message_length=GRPC_MAX_MESSAGE_LENGTH,
305
- interceptors=interceptors or None,
292
+ interceptors=[CliAccountAuthInterceptor(auth_plugin)],
306
293
  )
307
294
  channel.subscribe(on_channel_state_change)
308
295
  return channel
309
296
 
310
297
 
311
298
  @contextmanager
312
- def flwr_cli_grpc_exc_handler() -> Iterator[None]:
299
+ def flwr_cli_grpc_exc_handler() -> Iterator[None]: # pylint: disable=too-many-branches
313
300
  """Context manager to handle specific gRPC errors.
314
301
 
315
302
  It catches grpc.RpcError exceptions with UNAUTHENTICATED, UNIMPLEMENTED,
@@ -367,16 +354,21 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
367
354
  bold=True,
368
355
  )
369
356
  raise typer.Exit(code=1) from None
370
- if (
371
- e.code() == grpc.StatusCode.NOT_FOUND
372
- and e.details() == RUN_ID_NOT_FOUND_MESSAGE # pylint: disable=E1101
373
- ):
374
- typer.secho(
375
- "❌ Run ID not found.",
376
- fg=typer.colors.RED,
377
- bold=True,
378
- )
379
- raise typer.Exit(code=1) from None
357
+ if e.code() == grpc.StatusCode.NOT_FOUND:
358
+ if e.details() == RUN_ID_NOT_FOUND_MESSAGE: # pylint: disable=E1101
359
+ typer.secho(
360
+ "❌ Run ID not found.",
361
+ fg=typer.colors.RED,
362
+ bold=True,
363
+ )
364
+ raise typer.Exit(code=1) from None
365
+ if e.details() == NODE_NOT_FOUND_MESSAGE: # pylint: disable=E1101
366
+ typer.secho(
367
+ "❌ Node ID not found for this account.",
368
+ fg=typer.colors.RED,
369
+ bold=True,
370
+ )
371
+ raise typer.Exit(code=1) from None
380
372
  if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
381
373
  if e.details() == PULL_UNFINISHED_RUN_MESSAGE: # pylint: disable=E1101
382
374
  typer.secho(
@@ -386,4 +378,22 @@ def flwr_cli_grpc_exc_handler() -> Iterator[None]:
386
378
  bold=True,
387
379
  )
388
380
  raise typer.Exit(code=1) from None
381
+ if (
382
+ e.details() == PUBLIC_KEY_ALREADY_IN_USE_MESSAGE
383
+ ): # pylint: disable=E1101
384
+ typer.secho(
385
+ "❌ The provided public key is already in use by another "
386
+ "SuperNode.",
387
+ fg=typer.colors.RED,
388
+ bold=True,
389
+ )
390
+ raise typer.Exit(code=1) from None
391
+ if e.details() == PUBLIC_KEY_NOT_VALID: # pylint: disable=E1101
392
+ typer.secho(
393
+ "❌ The provided public key is invalid. Please provide a valid "
394
+ "NIST EC public key.",
395
+ fg=typer.colors.RED,
396
+ bold=True,
397
+ )
398
+ raise typer.Exit(code=1) from None
389
399
  raise