Skip to content
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

Open
wants to merge 67 commits into
base: main
Choose a base branch
from
Open

Adding oauthn #690

wants to merge 67 commits into from

Conversation

ZohebShaikh
Copy link
Contributor

@ZohebShaikh ZohebShaikh commented Oct 25, 2024

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.

Copy link

codecov bot commented Oct 25, 2024

Codecov Report

Attention: Patch coverage is 98.38057% with 4 lines in your changes missing coverage. Please review.

Project coverage is 93.10%. Comparing base (33908fd) to head (07c9686).
Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/blueapi/service/main.py 87.50% 4 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

Copy link
Contributor

@tpoliaw tpoliaw left a 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.

src/blueapi/client/rest.py Outdated Show resolved Hide resolved
src/blueapi/service/authentication.py Outdated Show resolved Hide resolved
src/blueapi/service/authentication.py Outdated Show resolved Hide resolved
src/blueapi/service/authentication.py Outdated Show resolved Hide resolved
src/blueapi/service/authentication.py Outdated Show resolved Hide resolved
cached_valid_token: Path,
):
plan = Plan(name="my-plan", model=MyModel)
mock_authn_server.stop() # Cannot use multiple RequestsMock context manager
Copy link
Contributor

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/config.py Outdated Show resolved Hide resolved
src/blueapi/cli/cli.py Outdated Show resolved Hide resolved
src/blueapi/service/authentication.py Outdated Show resolved Hide resolved
async def on_token_error_401(_: Request, __: Exception):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={},
Copy link
Contributor

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

Copy link
Contributor Author

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

Copy link
Contributor

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

Copy link
Contributor

Choose a reason for hiding this comment

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

All challenges defined by this specification MUST use the auth-scheme
value "Bearer". This scheme MUST be followed by one or more
auth-param values. The auth-param attributes used or defined by this
specification are as follows. Other auth-param attributes MAY be
used as well.

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.

@ZohebShaikh
Copy link
Contributor Author

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.

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 token_file_path, which could be moved to the cached directory by default.

Now, when the server has authentication enabled, how should the login feature behave? For example, if a user runs a command like blueapi -c config controller plans and the client doesn’t have an access token, should it prompt the user to log in automatically? Or should it let the request fail with a 404 error and leave it up to the user to realize they need to log in?

@tpoliaw
Copy link
Contributor

tpoliaw commented Nov 22, 2024

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 Show resolved Hide resolved
Comment on lines 360 to 362
access_token = auth.get_valid_access_token()
if not access_token:
print("Problem with cached token, starting new session")
Copy link
Contributor

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?

Copy link
Contributor

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

Comment on lines 369 to 375
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()
Copy link
Contributor

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?

src/blueapi/cli/cli.py Outdated Show resolved Hide resolved
src/blueapi/cli/cli.py Outdated Show resolved Hide resolved

def save_cache(self, cache: Cache) -> None:
cache_json: str = cache.model_dump_json()
cache_base64: bytes = base64.b64encode(cache_json.encode("utf-8"))
Copy link
Contributor

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

Copy link
Contributor Author

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.
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Comment on lines 54 to 55
except Exception:
return None
Copy link
Contributor

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

Comment on lines 105 to 106
return ""
return ""
Copy link
Contributor

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.

async def on_token_error_401(_: Request, __: Exception):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={},
Copy link
Contributor

Choose a reason for hiding this comment

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

All challenges defined by this specification MUST use the auth-scheme
value "Bearer". This scheme MUST be followed by one or more
auth-param values. The auth-param attributes used or defined by this
specification are as follows. Other auth-param attributes MAY be
used as well.

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.

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.

3 participants