-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adding oauthn #690
base: main
Are you sure you want to change the base?
Adding oauthn #690
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #690 +/- ##
==========================================
+ Coverage 92.17% 93.10% +0.92%
==========================================
Files 35 36 +1
Lines 1803 2029 +226
==========================================
+ Hits 1662 1889 +227
+ Misses 141 140 -1 ☔ View full report in Codecov by Sentry. |
90fdb41
to
f38e0c3
Compare
bf18693
to
7c9779b
Compare
cf632d0
to
3b06ea0
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A couple of biggish design level things:
If we're assuming the access tokens are short-lived, single use things, would it make more sense (and probably simplify the code) to request a new token for every request? When a user logs in, only store the refresh token, then for every subsequent request, request a new access token and use that. It means we don't need to do any expiry checks and the rest client doesn't have to aware of any refresh requests.
Secondly, should the client request the authentication details from the server instead of having to provide its own config? It's a pain for a user to have to pass configuration when presumably it doesn't make sense for it to ever be different from the configuration the server is going to use to validate it? We could have a login
endpoint that returns the configuration to use. Presumably any future web client would need something similar to initiate to login process.
For the token file location, it could be an optional flag to the CLI defaulting to somewhere like $XDG_CACHE_DIR/blueapi_token
.
cached_valid_token: Path, | ||
): | ||
plan = Plan(name="my-plan", model=MyModel) | ||
mock_authn_server.stop() # Cannot use multiple RequestsMock context manager |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't this be handled by the fixture instead of needing it in every test?
src/blueapi/service/main.py
Outdated
async def on_token_error_401(_: Request, __: Exception): | ||
return JSONResponse( | ||
status_code=status.HTTP_401_UNAUTHORIZED, | ||
content={}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The spec wants this to return a WWW-Authenticate
header (see docs). Something like WWW-Authenticate=Bearer realm="blueapi"
? I'm not sure what the realm/scopes should be
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This specification outlines the expected response from Keycloak when it encounters an invalid token, Not for what a fast-api/any other server how it should handle invalid requests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty sure it refers to services like this
If the protected resource request does not include authentication
credentials or does not contain an access token that enables access
to the protected resource, the resource server MUST include the HTTP
"WWW-Authenticate" response header field
Or from the HTTP status code spec
The 401 (Unauthorized) status code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource. The server generating a 401 response MUST send a WWW-Authenticate header field
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the protected resource request included an access token and failed
authentication, the resource server SHOULD include the "error"
attribute to provide the client with the reason why the access
request was declined.
3.1. Error Codes
When a request fails, the resource server responds using the
appropriate HTTP status code (typically, 400, 401, 403, or 405) and
includes one of the following error codes in the response:
invalid_request
The request is missing a required parameter, includes an
unsupported parameter or parameter value, repeats the same
parameter, uses more than one method for including an access
token, or is otherwise malformed. The resource server SHOULD
respond with the HTTP 400 (Bad Request) status code.
invalid_token
The access token provided is expired, revoked, malformed, or
invalid for other reasons. The resource SHOULD respond with
the HTTP 401 (Unauthorized) status code. The client MAY
request a new access token and retry the protected resource
request.
insufficient_scope
The request requires higher privileges than provided by the
access token. The resource server SHOULD respond with the HTTP
403 (Forbidden) status code and MAY include the "scope"
attribute with the scope necessary to access the protected
resource.
If the request lacks any authentication information (e.g., the client
was unaware that authentication is necessary or attempted using an
unsupported authentication method), the resource server SHOULD NOT
include an error code or other error information.
Quotes end:
return JsonResponse(status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Bearer realm=blueapi"})```
seems a reasonable first attempt. According to the spec we SHOULD check what type of JWTException we get and set the error
appropriately too.
The first suggestion makes sense and seems straightforward to implement. Regarding point 2, I wanted to add something: the configuration currently includes three pieces of information, all of which are optional. The only critical detail is the Now, when the server has authentication enabled, how should the login feature behave? For example, if a user runs a command like |
If the server has auth enabled and the user doesn't pass a valid token, I think returning 401 and letting the user figure it out (via a 'Authentication required' message to make it obvious?) is the easiest option although automatically trying to login could work too as long as there's a way to prevent it so commands can be scripted. For the first point, I think the auth endpoint would need to be cached as well as the refresh token so that the login details would not need to be queried for every request. |
src/blueapi/cli/cli.py
Outdated
access_token = auth.get_valid_access_token() | ||
if not access_token: | ||
print("Problem with cached token, starting new session") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should: this print should be in the except block for the get_valid_access_token, as this path also has the case of there not being a cached token?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then delete_cache also in there
src/blueapi/cli/cli.py
Outdated
client = BlueapiClient.from_config(config) | ||
oidc_config = client.get_oidc_config() | ||
auth = SessionManager( | ||
oidc_config, cache_manager=SessionCacheManager(config.auth_token_path) | ||
) | ||
print("Logging in") | ||
auth.start_device_flow() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This path is when SessionManager.from_cache(config.auth_token_path) == None
: how is its behaviour different? Are we not in a fatal misconfiguration for logging in at this point?
|
||
def save_cache(self, cache: Cache) -> None: | ||
cache_json: str = cache.model_dump_json() | ||
cache_base64: bytes = base64.b64encode(cache_json.encode("utf-8")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did we decide we weren't going to base64 encode this before saving? It'd make it slightly simpler
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we though we will keep it
cache_dir = os.environ.get("XDG_CACHE_HOME") | ||
if not cache_dir: | ||
cache_dir = os.path.expanduser(BLUEAPI_CACHE_LOCATION) | ||
if cache_dir.startswith("~/"): # Expansion failed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should: What are we actually testing for here? Do we need an absolute path, in which case we should make a Path and check that it is absolute: and that the directory exists. Currently we allow a relative path if it's not in the home directory, and allow paths that may not exist.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test checks whether the ~/
relative expansion has failed. If the path is absolute, it will remain unchanged. Using relative paths is simply more convenient than using absolute ones.
except Exception: | ||
return None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you remove these lines, the Exception will propagate up out of this method and you don't need to account for the | None case until wherever you want to catch the Exception
return "" | ||
return "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As above, if you remove the path where cache is None, you don't have to catch this empty string elsewhere.
src/blueapi/service/main.py
Outdated
async def on_token_error_401(_: Request, __: Exception): | ||
return JSONResponse( | ||
status_code=status.HTTP_401_UNAUTHORIZED, | ||
content={}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the protected resource request included an access token and failed
authentication, the resource server SHOULD include the "error"
attribute to provide the client with the reason why the access
request was declined.
3.1. Error Codes
When a request fails, the resource server responds using the
appropriate HTTP status code (typically, 400, 401, 403, or 405) and
includes one of the following error codes in the response:
invalid_request
The request is missing a required parameter, includes an
unsupported parameter or parameter value, repeats the same
parameter, uses more than one method for including an access
token, or is otherwise malformed. The resource server SHOULD
respond with the HTTP 400 (Bad Request) status code.
invalid_token
The access token provided is expired, revoked, malformed, or
invalid for other reasons. The resource SHOULD respond with
the HTTP 401 (Unauthorized) status code. The client MAY
request a new access token and retry the protected resource
request.
insufficient_scope
The request requires higher privileges than provided by the
access token. The resource server SHOULD respond with the HTTP
403 (Forbidden) status code and MAY include the "scope"
attribute with the scope necessary to access the protected
resource.
If the request lacks any authentication information (e.g., the client
was unaware that authentication is necessary or attempted using an
unsupported authentication method), the resource server SHOULD NOT
include an error code or other error information.
Quotes end:
return JsonResponse(status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Bearer realm=blueapi"})```
seems a reasonable first attempt. According to the spec we SHOULD check what type of JWTException we get and set the error
appropriately too.
Alternative to the authentication were investigated:-
Client Libraries
In the end I decided to not use them as we just need to make 2 requests and there is not that much error handling required as well ... We can look into integrating on of the above mentioned alternatives for the OAuth device flow integration.