-
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.
Merge pull request #877 from lsst-sqre/tickets/DM-41186
DM-41186: Add new route for CADC token metadata
- 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 |