Skip to content

Commit

Permalink
Merge pull request #877 from lsst-sqre/tickets/DM-41186
Browse files Browse the repository at this point in the history
DM-41186: Add new route for CADC token metadata
  • Loading branch information
rra authored Oct 25, 2023
2 parents 50f3c1b + 57429d9 commit 96428d3
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 1 deletion.
3 changes: 3 additions & 0 deletions changelog.d/20231013_131313_rra_DM_41186.md
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.
8 changes: 8 additions & 0 deletions src/gafaelfawr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ipaddress import IPv4Network, IPv6Network
from pathlib import Path
from typing import Self
from uuid import UUID

import yaml
from pydantic import (
Expand Down Expand Up @@ -446,6 +447,9 @@ class Settings(CamelCaseModel):
slack_webhook_file: Path | None = None
"""File containing the Slack webhook to which to post alerts."""

cadc_base_uuid: UUID | None = None
"""Namespace UUID used to generate UUIDs for CADC-compatible auth."""

github: GitHubSettings | None = None
"""Settings for the GitHub authentication provider."""

Expand Down Expand Up @@ -856,6 +860,9 @@ class Config:
slack_webhook: str | None
"""Slack webhook to which to post alerts."""

cadc_base_uuid: UUID | None
"""Namespace UUID used to generate UUIDs for CADC-compatible auth."""

github: GitHubConfig | None
"""Configuration for GitHub authentication."""

Expand Down Expand Up @@ -1071,6 +1078,7 @@ def from_file(cls, path: Path) -> Self: # noqa: PLR0912,PLR0915,C901
after_logout_url=str(settings.after_logout_url),
error_footer=settings.error_footer,
slack_webhook=slack_webhook,
cadc_base_uuid=settings.cadc_base_uuid,
github=github_config,
oidc=oidc_config,
ldap=ldap_config,
Expand Down
87 changes: 87 additions & 0 deletions src/gafaelfawr/handlers/cadc.py
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)),
)
3 changes: 2 additions & 1 deletion src/gafaelfawr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from .constants import COOKIE_NAME
from .dependencies.config import config_dependency
from .dependencies.context import context_dependency
from .handlers import analyze, api, auth, index, login, logout, oidc
from .handlers import analyze, api, auth, cadc, index, login, logout, oidc
from .middleware.state import StateMiddleware
from .models.state import State

Expand Down Expand Up @@ -95,6 +95,7 @@ def create_app(*, load_config: bool = True) -> FastAPI:
},
)
app.include_router(auth.router)
app.include_router(cadc.router)
app.include_router(index.router)
app.include_router(login.router)
app.include_router(logout.router)
Expand Down
44 changes: 44 additions & 0 deletions src/gafaelfawr/models/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import datetime
from enum import Enum
from typing import Any, Self
from uuid import UUID

from pydantic import (
BaseModel,
Expand All @@ -23,6 +24,7 @@

__all__ = [
"AdminTokenRequest",
"CADCUserInfo",
"NewToken",
"NotebookQuota",
"Quota",
Expand Down Expand Up @@ -693,3 +695,45 @@ class UserTokenModifyRequest(BaseModel):
),
examples=[1625986130],
)


class CADCUserInfo(BaseModel):
"""User metadata required by the CADC authentication code.
This model is hopefully temporary and will be retired by merging the CADC
support with the OpenID Connect support.
"""

exp: datetime | None = Field(
None,
title="Expiration time",
description=(
"Expiration timestamp of the token in seconds since epoch. If"
" not present, the token never expires."
),
examples=[1625986130],
)

preferred_username: str = Field(
...,
title="Username",
description="Username of user",
examples=["someuser"],
)

sub: UUID = Field(
...,
title="Unique identifier",
description=(
"CADC code currently requires a UUID in this field. In practice,"
" this is generated as a version 5 UUID based on a configured"
" UUID namespace and the string representation of the user's"
" numeric UID (thus allowing username changes if the UID is"
" preserved."
),
examples=["78410c93-841d-53b1-a353-6411524d149e"],
)

@field_serializer("exp")
def _serialize_datetime(self, time: datetime | None) -> int | None:
return int(time.timestamp()) if time is not None else None
1 change: 1 addition & 0 deletions tests/data/config/github.yaml.in
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ github:
clientSecretFile: "{github_secret_file}"
errorFooter: |
Some <strong>error instructions</strong> with HTML.
cadcBaseUuid: "750a0d94-e0eb-4b4e-a732-bcf87d7197fd"
96 changes: 96 additions & 0 deletions tests/handlers/cadc_test.py
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

0 comments on commit 96428d3

Please sign in to comment.