Skip to content

Commit 63b45f7

Browse files
authored
feat: Backlog/monorepo refactor 2025/authenticate library (#607)
1 parent 1427d3b commit 63b45f7

File tree

40 files changed

+1148
-25
lines changed

40 files changed

+1148
-25
lines changed

.pre-commit-config.yaml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
repos:
2-
- repo: https://github.com/psf/black
3-
rev: 23.1.0
4-
hooks:
5-
- id: black
6-
language_version: python3.10
7-
exclude: tools/cookiecutter*
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
# Ruff version.
4+
rev: v0.8.3
5+
hooks:
6+
# Run the linter.
7+
- id: ruff
8+
args: [ --fix ]
9+
# Run the formatter.
10+
- id: ruff-format
File renamed without changes.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# :coding: utf-8
2+
# :copyright: Copyright (c) 2024 ftrack
3+
4+
import logging
5+
import threading
6+
import webbrowser
7+
from typing import TYPE_CHECKING, Optional
8+
9+
from ftrack.library.utility.url.checker import ftrack_server_url_checker
10+
11+
from .util.identifier import generate_url_identifier
12+
13+
if TYPE_CHECKING:
14+
from .helper.credential import (
15+
CredentialFactory,
16+
CredentialInterface,
17+
)
18+
from .helper.webserver import WebServerFactory, WebServerInterface
19+
20+
# Configure logging
21+
logging.basicConfig(level=logging.INFO)
22+
23+
24+
class Authenticate:
25+
"""
26+
Handles authentication by opening a browser and running a local web server
27+
to capture credential via callback.
28+
"""
29+
30+
def __init__(
31+
self,
32+
server_url: str,
33+
credential_factory: "CredentialFactory",
34+
web_server_factory: "WebServerFactory",
35+
redirect_port: int = 5000,
36+
) -> None:
37+
"""
38+
Initialize the Authenticate instance.
39+
40+
:param server_url: The base URL for the authentication request.
41+
:param credential_factory: The credential provider factory instance.
42+
:param web_server_factory: The web server factory instance.
43+
:param redirect_port: The port on which the local web server will run.
44+
"""
45+
try:
46+
self._server_url: str = ftrack_server_url_checker(server_url)
47+
except ValueError as e:
48+
logging.error(f"Invalid server URL: {e}")
49+
raise
50+
self._credential_instance: "CredentialInterface" = credential_factory.make()
51+
self._web_server_factory: "WebServerFactory" = web_server_factory
52+
self._web_server_instance: Optional["WebServerInterface"] = None
53+
self._redirect_port: int = redirect_port
54+
self._redirect_uri: str = f"http://localhost:{redirect_port}/callback"
55+
self._server_ready: threading.Event = threading.Event()
56+
57+
@property
58+
def server_url(self):
59+
return self._server_url
60+
61+
@property
62+
def credential_instance(self):
63+
return self._credential_instance
64+
65+
@property
66+
def web_server_factory(self):
67+
return self._web_server_factory
68+
69+
@property
70+
def web_server_instance(self):
71+
return self._web_server_instance
72+
73+
@property
74+
def redirect_port(self):
75+
return self._redirect_port
76+
77+
@property
78+
def redirect_uri(self):
79+
return self._redirect_uri
80+
81+
def authenticate_browser(self) -> None:
82+
"""
83+
Launch the authentication process:
84+
- Starts a local web server in a separate thread.
85+
- Opens the authentication URL in the default web browser.
86+
- Waits for the web server to handle the callback and capture credential.
87+
"""
88+
try:
89+
# Create a web server instance
90+
self.web_server_factory.credential_instance = self.credential_instance
91+
self.web_server_factory.server_url = self.server_url
92+
self.web_server_factory.port = self.redirect_port
93+
self._web_server_instance = self.web_server_factory.make()
94+
95+
# Start the web server in a separate thread
96+
server_thread: threading.Thread = threading.Thread(
97+
target=self.run_server, daemon=True
98+
)
99+
server_thread.start()
100+
101+
# Wait until the server is ready before proceeding
102+
self._server_ready.wait()
103+
104+
# Format the authentication URL
105+
auth_url: str = f"{self.server_url}/user/api_credentials?identifier={generate_url_identifier('ftrack-connect')}&redirect_url={self.redirect_uri}"
106+
107+
# Log the URL being opened
108+
logging.info(f"Opening browser for authentication: {auth_url}")
109+
110+
# Open the authentication URL in the browser
111+
webbrowser.open_new_tab(auth_url)
112+
113+
# Wait for the server to shut down after successful authentication
114+
server_thread.join()
115+
except Exception as e:
116+
logging.error(f"An error occurred during browser authentication: {e}")
117+
raise
118+
119+
def run_server(self) -> None:
120+
"""
121+
Start the web server and notify the main thread that it's ready.
122+
"""
123+
self._server_ready.set()
124+
self.web_server_instance.run_server()
125+
126+
def authenticate_credential(
127+
self, server_url: str, api_user: str, api_key: str
128+
) -> None:
129+
"""
130+
Save captured credential securely using the utility method.
131+
132+
:param server_url: The server URL captured during authentication.
133+
:param api_user: The username captured during authentication.
134+
:param api_key: The API key captured during authentication.
135+
"""
136+
self.credential_instance.credential_store(server_url, api_user, api_key)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# :coding: utf-8
2+
# :copyright: Copyright (c) 2024 ftrack
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# :coding: utf-8
2+
# :copyright: Copyright (c) 2024 ftrack
3+
4+
import logging
5+
from abc import ABC, abstractmethod
6+
from typing import Dict, Optional, Type
7+
8+
import keyring
9+
10+
# Configure logging
11+
logging.basicConfig(level=logging.INFO)
12+
13+
14+
class CredentialInterface(ABC):
15+
"""
16+
Abstract base class for credential.
17+
"""
18+
19+
@abstractmethod
20+
def __init__(self, credential_identifier: str) -> None:
21+
"""
22+
Initialize the credential with a credential identifier.
23+
24+
:param credential_identifier: Unique identifier for credentials.
25+
"""
26+
pass
27+
28+
@abstractmethod
29+
def credential_load(self) -> Optional[Dict[str, str]]:
30+
"""
31+
Retrieve credentials.
32+
33+
:return: Dictionary containing server_url, api_user, and api_key, or None if not found.
34+
"""
35+
pass
36+
37+
@abstractmethod
38+
def credential_store(self, server_url: str, api_user: str, api_key: str) -> None:
39+
"""
40+
Save credentials securely.
41+
42+
:param server_url: Server URL.
43+
:param api_user: User's name.
44+
:param api_key: API key for authentication.
45+
"""
46+
pass
47+
48+
49+
class CredentialFactory:
50+
"""Factory class for creating credential."""
51+
52+
def __init__(
53+
self,
54+
credential_identifier: Optional[str] = None,
55+
variant: str = "keyring",
56+
) -> None:
57+
self._credential_identifier: Optional[str] = None
58+
self._variant: str = variant
59+
self._available_variant: Dict[str, Type["CredentialInterface"]] = {
60+
"keyring": KeyringCredential
61+
}
62+
self.credential_identifier: Optional[str] = credential_identifier
63+
64+
@property
65+
def credential_identifier(self) -> Optional[str]:
66+
return self._credential_identifier
67+
68+
@credential_identifier.setter
69+
def credential_identifier(self, value: Optional[str]) -> None:
70+
if not isinstance(value, str) and value is not None:
71+
raise ValueError("Credential identifier must be a string or None.")
72+
self._credential_identifier = value
73+
74+
@property
75+
def variant(self) -> str:
76+
return self._variant
77+
78+
@variant.setter
79+
def variant(self, value: str) -> None:
80+
if not isinstance(value, str):
81+
raise ValueError("Variant must be a string.")
82+
self._variant = value
83+
84+
@property
85+
def available_variant(self) -> Dict[str, Type["CredentialInterface"]]:
86+
return self._available_variant
87+
88+
def make(self) -> "CredentialInterface":
89+
"""
90+
Create and return a CredentialInterface-based credential.
91+
92+
:return: Instance of a CredentialInterface.
93+
"""
94+
if not self.credential_identifier:
95+
raise ValueError("Credential identifier is required.")
96+
return self.available_variant[self.variant](self.credential_identifier)
97+
98+
99+
class KeyringCredential(CredentialInterface):
100+
"""
101+
Credential that uses the system keyring to store and retrieve credentials.
102+
"""
103+
104+
def __init__(self, credential_identifier: str) -> None:
105+
self._credential_identifier: str = credential_identifier
106+
107+
@property
108+
def credential_identifier(self) -> str:
109+
return self._credential_identifier
110+
111+
def credential_load(self) -> Optional[Dict[str, str]]:
112+
"""
113+
Retrieve credentials from the keyring.
114+
115+
:return: Dictionary containing server_url, api_user, and api_key, or None if not found.
116+
"""
117+
try:
118+
server_url = keyring.get_password(self.credential_identifier, "server_url")
119+
api_user = keyring.get_password(self.credential_identifier, "api_user")
120+
api_key = keyring.get_password(self.credential_identifier, "api_key")
121+
122+
if server_url and api_user and api_key:
123+
return {
124+
"server_url": server_url,
125+
"api_user": api_user,
126+
"api_key": api_key,
127+
}
128+
129+
logging.warning(
130+
f"No credential found for identifier: {self.credential_identifier}."
131+
)
132+
return None
133+
except Exception as e:
134+
logging.error(f"Failed to retrieve credential: {e}")
135+
raise
136+
137+
def credential_store(self, server_url: str, api_user: str, api_key: str) -> None:
138+
"""
139+
Save credentials securely using the system keyring.
140+
141+
:param server_url: Server URL.
142+
:param api_user: User's name.
143+
:param api_key: API key for authentication.
144+
"""
145+
try:
146+
keyring.set_password(self.credential_identifier, "server_url", server_url)
147+
keyring.set_password(self.credential_identifier, "api_user", api_user)
148+
keyring.set_password(self.credential_identifier, "api_key", api_key)
149+
logging.info(
150+
f"Credentials saved for identifier: {self.credential_identifier}."
151+
)
152+
except Exception as e:
153+
logging.error(f"Failed to save credential: {e}")
154+
raise

0 commit comments

Comments
 (0)