-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add new route for CADC token metadata
CADC software uses an authentication component designed for use with OpenID Connect that takes a token from the Authorization header and sends it to an endpoint, expecting to get back the validated JWT claims. Since it treats the token as opaque, we can use this with our regular tokens as long as we provide an endpoint that returns user metadata in the expected format. Add /auth/cadc/userinfo as that endpoint, temporarily. Put CADC in the URL because the return format is specific to the current needs of the CADC auth code. In particular, it requires sub to be a UUID (to allow username changes), which we generate based on an optional configuration parameter and the UID of the user using the UUID v5 algorithm.
- Loading branch information
Showing
7 changed files
with
241 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
### New features | ||
|
||
- Added new `/auth/cadc/userinfo` route, which accepts a Gafaelfawr token and returns user metadata in the format expected by the CADC authentication code. This route is expected to be temporary and will be moved into the main token API once we decide how to handle uniqueness of the `sub` claim. It is therefore not currently documented outside of the autogenerated API documentation. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
"""Handlers for CADC authentication integration (``/auth/cadc``). | ||
Hopefully this can eventually be integrated with the OpenID Connect handlers | ||
by allowing the ``/auth/openid/userinfo`` endpoint to take either an OpenID | ||
Connect token or a Gafaelfawr token and return JWT-compatible claims, but | ||
currently CADC code requires ``sub`` be a UUID and we have other integrations | ||
that use ``sub`` as a username. | ||
""" | ||
|
||
from uuid import uuid5 | ||
|
||
from fastapi import APIRouter, Depends, HTTPException, status | ||
from safir.models import ErrorModel | ||
from safir.slack.webhook import SlackRouteErrorHandler | ||
|
||
from ..dependencies.auth import AuthenticateRead | ||
from ..dependencies.context import RequestContext, context_dependency | ||
from ..exceptions import ( | ||
ExternalUserInfoError, | ||
NotConfiguredError, | ||
PermissionDeniedError, | ||
) | ||
from ..models.token import CADCUserInfo, TokenData | ||
|
||
__all__ = ["router"] | ||
|
||
router = APIRouter( | ||
responses={ | ||
404: { | ||
"description": "CADC integration not configured", | ||
"model": ErrorModel, | ||
}, | ||
}, | ||
route_class=SlackRouteErrorHandler, | ||
) | ||
authenticate_read = AuthenticateRead() | ||
|
||
|
||
@router.get( | ||
"/auth/cadc/userinfo", | ||
description=( | ||
"Return metadata about the authenticated user in a format similar to" | ||
" that of OpenID Connect JWT claims and meeting the specific" | ||
" requirements of CADC's authentication code. This API is expected to" | ||
" be temporary and to be merged into a different route in a future" | ||
" version." | ||
), | ||
response_model=CADCUserInfo, | ||
response_model_exclude_none=True, | ||
responses={ | ||
401: {"description": "Unauthenticated"}, | ||
403: {"description": "Permission denied", "model": ErrorModel}, | ||
}, | ||
summary="Get CADC-compatible user metadata", | ||
tags=["oidc"], | ||
) | ||
async def get_userinfo( | ||
auth_data: TokenData = Depends(authenticate_read), | ||
context: RequestContext = Depends(context_dependency), | ||
) -> CADCUserInfo: | ||
config = context.config | ||
if not config.cadc_base_uuid: | ||
msg = "CADC-compatible authentication not configured" | ||
raise NotConfiguredError(msg) | ||
user_info_service = context.factory.create_user_info_service() | ||
try: | ||
user_info = await user_info_service.get_user_info_from_token(auth_data) | ||
except ExternalUserInfoError as e: | ||
msg = "Unable to get user information" | ||
context.logger.exception(msg, error=str(e)) | ||
slack_client = context.factory.create_slack_client() | ||
if slack_client: | ||
await slack_client.post_exception(e) | ||
raise HTTPException( | ||
headers={"Cache-Control": "no-cache, no-store"}, | ||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
detail=[{"msg": msg, "type": "user_info_failed"}], | ||
) from e | ||
if not user_info.uid: | ||
error = "User has no UID" | ||
context.logger.warning("Cannot generate CADC auth data", error=error) | ||
raise PermissionDeniedError(error) | ||
return CADCUserInfo( | ||
exp=auth_data.expires, | ||
preferred_username=auth_data.username, | ||
sub=uuid5(config.cadc_base_uuid, str(user_info.uid)), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
"""Tets for the ``/auth/cadc`` routes.""" | ||
|
||
from __future__ import annotations | ||
|
||
from datetime import timedelta | ||
from pathlib import Path | ||
from uuid import uuid5 | ||
|
||
import pytest | ||
from httpx import AsyncClient | ||
from safir.datetime import current_datetime | ||
|
||
from gafaelfawr.config import Config | ||
from gafaelfawr.factory import Factory | ||
from gafaelfawr.models.token import ( | ||
AdminTokenRequest, | ||
Token, | ||
TokenData, | ||
TokenType, | ||
) | ||
|
||
from ..support.config import reconfigure | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_userinfo( | ||
config: Config, client: AsyncClient, factory: Factory | ||
) -> None: | ||
assert config.cadc_base_uuid | ||
expires = current_datetime() + timedelta(days=7) | ||
request = AdminTokenRequest( | ||
username="bot-example", | ||
token_type=TokenType.service, | ||
uid=45613, | ||
expires=expires, | ||
) | ||
token_service = factory.create_token_service() | ||
async with factory.session.begin(): | ||
token = await token_service.create_token_from_admin_request( | ||
request, TokenData.internal_token(), ip_address=None | ||
) | ||
|
||
r = await client.get( | ||
"/auth/cadc/userinfo", headers={"Authorization": f"bearer {token}"} | ||
) | ||
assert r.status_code == 200 | ||
assert r.json() == { | ||
"exp": int(expires.timestamp()), | ||
"preferred_username": "bot-example", | ||
"sub": str(uuid5(config.cadc_base_uuid, "45613")), | ||
} | ||
|
||
|
||
@pytest.mark.asyncio | ||
async def test_userinfo_errors( | ||
config: Config, client: AsyncClient, factory: Factory, tmp_path: Path | ||
) -> None: | ||
assert config.cadc_base_uuid | ||
|
||
r = await client.get("/auth/cadc/userinfo") | ||
assert r.status_code == 401 | ||
r = await client.get( | ||
"/auth/cadc/userinfo", headers={"Authorization": f"bearer {Token()!s}"} | ||
) | ||
assert r.status_code == 401 | ||
r = await client.get( | ||
"/auth/cadc/userinfo", headers={"Authorization": "bearer blahblah"} | ||
) | ||
assert r.status_code == 401 | ||
|
||
# Create a token that doesn't have a UID. We cannot generate a UUID for | ||
# these, so they will produce an error. | ||
request = AdminTokenRequest( | ||
username="bot-example", token_type=TokenType.service | ||
) | ||
token_service = factory.create_token_service() | ||
async with factory.session.begin(): | ||
token = await token_service.create_token_from_admin_request( | ||
request, TokenData.internal_token(), ip_address=None | ||
) | ||
r = await client.get( | ||
"/auth/cadc/userinfo", headers={"Authorization": f"bearer {token!s}"} | ||
) | ||
assert r.status_code == 403 | ||
|
||
# Switch to a configuration that doesn't have CADC auth configuration. | ||
await reconfigure(tmp_path, "github-quota", factory) | ||
token_service = factory.create_token_service() | ||
async with factory.session.begin(): | ||
token = await token_service.create_token_from_admin_request( | ||
request, TokenData.internal_token(), ip_address=None | ||
) | ||
r = await client.get( | ||
"/auth/cadc/userinfo", headers={"Authorization": f"bearer {token!s}"} | ||
) | ||
assert r.status_code == 404 |