Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e7f388c
Add OIDC login
ajkyffin Jun 6, 2025
cc03143
Fix typo in oidc_login method
louise-davies Jun 30, 2025
0cd2ef5
Set verify_exp: True in decode options for OidcHandler
patrick-austin Jul 31, 2025
79c49de
Rework that adds support for client_secret and caching and also break…
ajkyffin Sep 11, 2025
447aff0
oidc_redirect_url changed to oidc_redirect_uri
ajkyffin Sep 11, 2025
efaf442
Use str type hint instead of HttpUrl
ajkyffin Sep 11, 2025
e8f5998
Fix issuer check
ajkyffin Sep 22, 2025
f51d8ec
Add leeway to oidc payload check
ajkyffin Sep 22, 2025
df6f743
Remove extra second of leeway which was based on incorrect logic
ajkyffin Sep 22, 2025
4656d08
Fix linting errors
ajkyffin Oct 8, 2025
dbaf6a1
Fix linting error
ajkyffin Oct 8, 2025
35d716c
Fix import error caused by incomplete OIDCICATAuthenticator class rename
louise-davies Oct 9, 2025
529514a
Missing parentheses calling r.raise_for_status
ajkyffin Oct 13, 2025
9a8768f
Add tests for OIDC backend
ajkyffin Oct 14, 2025
eeaf1aa
Add oidc.get_provider_config function
ajkyffin Oct 14, 2025
13cf647
Use jwt.PyJWKSet to read keys
ajkyffin Oct 14, 2025
0b4d50a
Add check for JWK use
ajkyffin Oct 14, 2025
4f961a4
Add check for calling the token endpoint without a client_secret
ajkyffin Oct 15, 2025
f5ee06c
Refactor ICATAuthenticator so that OIDCICATAuthenticator is not needed
ajkyffin Oct 15, 2025
7d0c8e7
Move non-icat calls out of except ICATAuthenticationError block
ajkyffin Oct 15, 2025
b32a6f0
Add docstrings
ajkyffin Oct 31, 2025
f79eb57
Add OIDC config documentation to README.md
ajkyffin Dec 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,68 @@ Listed below are the environment variables supported by the application.
| `ICAT_SERVER__CERTIFICATE_VALIDATION` | Whether to verify ICAT certificates using its internal trust store or disable certificate validation completely. | Yes | |
| `ICAT_SERVER__REQUEST_TIMEOUT_SECONDS` | The maximum number of seconds that the request should wait for a response from ICAT before timing out. | Yes | |

### OIDC Configuration

The following environment variables are only required when using OIDC authentication:

| Environment Variable | Description | Mandatory | Default Value |
|-------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|-----------|---------------|
| `AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR` | The mnemonic of the ICAT authenticator. Usually `delegating`. | Yes | |
| `AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR_TOKEN` | The secret token to pass to the ICAT authenticator. | Yes | |
| `AUTHENTICATION__OIDC_REDIRECT_URI` | Redirect URI. Required if a `client_secret` is used. | No | |
| `AUTHENTICATION__OIDC_PROVIDERS` | A dictionary of OIDC provider configurations, indexed by `provider_id`. | Yes | |

To support multiple OIDC providers simultaneously, provider-specific config is indexed by a `provider_id`, e.g. to set the value of `DISPLAY_NAME` you would set the environment variable `AUTHENTICATION__OIDC_PROVIDERS__<provider_id>__DISPLAY_NAME`. The actual value used for `provider_id` is not important.

Each individual OIDC provider has the following configuration:

| Environment Variable | Description | Mandatory | Default Value |
|-------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|-----------|---------------|
| `DISPLAY_NAME` | The name of the OIDC provider to display in the frontend. | Yes | |
| `CONFIGURATION_URL` | The URL of the OIDC provider's configuration metadata. This will usually end with`/.well-known/openid-configuration`. | Yes | |
| `CLIENT_ID` | The `client_id` of the application registered with the OIDC provider. | Yes | |
| `CLIENT_SECRET` | The `client_secret`. If this is omitted then Authorization Code Flow with PKCE will be used (which is preferred). | No | |
| `VERIFY_CERT` | Whether to verify TLS certificates in calls to the OIDC provider. This should be `True`. | No | `True` |
| `MECHANISM` | The mechanism to prepend to the username in ICAT when using this OIDC provider. | No | |
| `SCOPE` | Which OAuth scopes to request. Must include `openid`. | No | `openid` |
| `USERNAME_CLAIM` | Which OAuth claim to use as the user's username. | No | `sub` |

### Example OIDC Configurations

This example uses Microsoft Single Sign-On. The format of the username will be determined by the tenant admin.
```
AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR="delegating"
AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR_TOKEN="fe1be44a35eb00ab46f5"
AUTHENTICATION__OIDC_PROVIDERS__sso__DISPLAY_NAME="Microsoft SSO"
AUTHENTICATION__OIDC_PROVIDERS__sso__CONFIGURATION_URL="https://login.microsoftonline.com/73c7442c-5f40-4db0-8dd2-5b9bb94516a1/v2.0/.well-known/openid-configuration"
AUTHENTICATION__OIDC_PROVIDERS__sso__CLIENT_ID="700bfc86-e26e-4638-a1a6-f7027106857b"
```

This example uses ORCID. The username will be the user's ORCID Id prepended with `orcid/`, e.g. `orcid/0000-0002-1825-0097`.
Since `client_secret` is used, `AUTHENTICATION__OIDC_REDIRECT_URI` must also be set.
```
AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR="delegating"
AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR_TOKEN="fe1be44a35eb00ab46f5"
AUTHENTICATION__OIDC_REDIRECT_URI="https://scigateway.example.com/login"
AUTHENTICATION__OIDC_PROVIDERS__orcid__DISPLAY_NAME="Orcid"
AUTHENTICATION__OIDC_PROVIDERS__orcid__CONFIGURATION_URL="https://orcid.org/.well-known/openid-configuration"
AUTHENTICATION__OIDC_PROVIDERS__orcid__CLIENT_ID="APP-QKUS1G0MLIOXDC57"
AUTHENTICATION__OIDC_PROVIDERS__orcid__CLIENT_SECRET="33182ac684744367edd8"
AUTHENTICATION__OIDC_PROVIDERS__orcid__MECHANISM="orcid"
```

This example uses Keycloak for testing with TLS certificate verification disabled. The username will be the user's email address (using custom settings for `SCOPE` and `USERNAME_CLAIM`).
```
AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR="delegating"
AUTHENTICATION__OIDC_ICAT_AUTHENTICATOR_TOKEN="fe1be44a35eb00ab46f5"
AUTHENTICATION__OIDC_PROVIDERS__keycloak__DISPLAY_NAME="Keycloak"
AUTHENTICATION__OIDC_PROVIDERS__keycloak__CONFIGURATION_URL="https://localhost:9000/realms/test-realm/.well-known/openid-configuration"
AUTHENTICATION__OIDC_PROVIDERS__keycloak__CLIENT_ID="test-client-id"
AUTHENTICATION__OIDC_PROVIDERS__keycloak__VERIFY_CERT="False"
AUTHENTICATION__OIDC_PROVIDERS__keycloak__SCOPE="openid email"
AUTHENTICATION__OIDC_PROVIDERS__keycloak__USERNAME_CLAIM="email"
```

### How to add or remove a JWT refresh token from the blacklist

The `AUTHENTICATION__JWT_REFRESH_TOKEN_BLACKLIST` environment variable holds the list of blacklisted JWT refresh tokens
Expand Down
14 changes: 13 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"PyJWT (>=2.9,<3.0)",
"cryptography (>=43.0)",
"fastapi[all] (>=0.123)",
"cachetools (>=6.2)",
]

[project.urls]
Expand Down
33 changes: 31 additions & 2 deletions scigateway_auth/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"""

from pathlib import Path
from typing import List
from typing import List, Self

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import BaseModel, ConfigDict, model_validator, SecretStr

To be used for secrets.

from pydantic_settings import BaseSettings, SettingsConfigDict


Expand All @@ -29,6 +29,21 @@ class MaintenanceConfig(BaseModel):
scheduled_maintenance_path: str


class OidcProviderConfig(BaseModel):
"""
Configuration model for an OIDC provider
"""

display_name: str
configuration_url: str
client_id: str
client_secret: str = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
client_secret: str = None
client_secret: SecretStr = None

SecretStr prevents secret values from appearing in logs, so might be useful here.

verify_cert: bool = True
mechanism: str = None
scope: str = "openid"
username_claim: str = "sub"


class AuthenticationConfig(BaseModel):
"""
Configuration model for the authentication.
Expand All @@ -43,6 +58,20 @@ class AuthenticationConfig(BaseModel):
# These are the ICAT usernames of the users normally in the <icat-mnemonic>/<username> form
admin_users: list[str]

oidc_providers: dict[str, OidcProviderConfig] = {}
oidc_redirect_uri: str = None
oidc_icat_authenticator: str = None
oidc_icat_authenticator_token: str = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
oidc_icat_authenticator_token: str = None
oidc_icat_authenticator_token: SecretStr = None

Would this also benefit from being a SecretStr?


@model_validator(mode="after")
def validate_oidc(self) -> Self:
if self.oidc_providers:
if not self.oidc_icat_authenticator:
raise ValueError("oidc_icat_authenticator is required if oidc_providers is set")
if not self.oidc_icat_authenticator_token:
raise ValueError("oidc_icat_authenticator_token is required if oidc_providers is set")
return self


class ICATServerConfig(BaseModel):
"""
Expand Down
6 changes: 6 additions & 0 deletions scigateway_auth/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,9 @@ class UserNotAdminError(Exception):
"""
Exception raised when a non-admin user performs an action that requires the user to be an admin.
"""


class OidcProviderNotFoundError(Exception):
"""
Exception raised when an OIDC provider is not found
"""
139 changes: 124 additions & 15 deletions scigateway_auth/routers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@

from fastapi import APIRouter, Body, Cookie, Depends, HTTPException, Response, status
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from scigateway_auth.common.config import config
from scigateway_auth.common.exceptions import (
BlacklistedJWTError,
ICATAuthenticationError,
InvalidJWTError,
JWTRefreshError,
OidcProviderNotFoundError,
UsernameMismatchError,
)
from scigateway_auth.common.schemas import LoginDetailsPostRequestSchema
from scigateway_auth.src import oidc
from scigateway_auth.src.authentication import ICATAuthenticator
from scigateway_auth.src.jwt_handler import JWTHandler

Expand All @@ -43,6 +46,27 @@ def get_authenticators():
) from exc


@router.get(
path="/oidc_providers",
summary="Get a list of OIDC providers",
response_description="Returns a list of OIDC providers",
)
def get_oidc_providers() -> JSONResponse:
logger.info("Getting a list of OIDC providers")

providers = {}
for provider_id, provider_config in config.authentication.oidc_providers.items():
providers[provider_id] = {
"display_name": provider_config.display_name,
"configuration_url": provider_config.configuration_url,
"client_id": provider_config.client_id,
"pkce": False if provider_config.client_secret else True,
"scope": provider_config.scope,
}
Comment on lines +59 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could probably do this with a combination of exclude and serialization aliases, but if it works as is then that's fine too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

nah


return JSONResponse(content=providers)


@router.post(
path="/login",
summary="Login with ICAT mnemonic and credentials",
Expand All @@ -56,28 +80,113 @@ def login(
],
) -> JSONResponse:
logger.info("Authenticating a user")

if login_details.credentials is None:
credentials = None
else:
credentials = {
"username": login_details.credentials.username.get_secret_value(),
"password": login_details.credentials.password.get_secret_value(),
}

try:
icat_session_id = ICATAuthenticator.authenticate(login_details.mnemonic, login_details.credentials)
icat_session_id = ICATAuthenticator.authenticate(login_details.mnemonic, credentials)
icat_username = ICATAuthenticator.get_username(icat_session_id)
except ICATAuthenticationError as exc:
logger.exception(exc.args)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc

access_token = jwt_handler.get_access_token(icat_session_id, icat_username)
refresh_token = jwt_handler.get_refresh_token(icat_username)

response = JSONResponse(content=access_token)
response.set_cookie(
key="scigateway:refresh_token",
value=refresh_token,
max_age=config.authentication.refresh_token_validity_days * 24 * 60 * 60,
secure=True,
httponly=True,
samesite="lax",
path=f"{config.api.root_path}/refresh",
)
return response


@router.post(
path="/oidc_token/{provider_id}",
summary="Get an OIDC id_token",
response_description="OIDC token endpoint response",
)
def oidc_token(
provider_id: Annotated[str, "OIDC provider id"],
code: Annotated[str, Body(description="OIDC authorization code")],
) -> JSONResponse:
logger.info("Obtaining an id_token")

try:
token_response = oidc.get_token(provider_id, code)
except OidcProviderNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Unknown OIDC provider: " + provider_id,
) from None

access_token = jwt_handler.get_access_token(icat_session_id, icat_username)
refresh_token = jwt_handler.get_refresh_token(icat_username)

response = JSONResponse(content=access_token)
response.set_cookie(
key="scigateway:refresh_token",
value=refresh_token,
max_age=config.authentication.refresh_token_validity_days * 24 * 60 * 60,
secure=True,
httponly=True,
samesite="lax",
path=f"{config.api.root_path}/refresh",
)
return response
return JSONResponse(content=token_response)


@router.post(
path="/oidc_login/{provider_id}",
summary="Login with an OIDC id token",
response_description="A JWT access token including a refresh token as an HTTP-only cookie",
)
def oidc_login(
jwt_handler: JWTHandlerDep,
provider_id: Annotated[str, "The OIDC provider id"],
bearer_token: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer(description="OIDC id token"))],
) -> JSONResponse:
logger.info("Authenticating a user")

id_token = bearer_token.credentials

try:
mechanism, oidc_username = oidc.get_username(provider_id, id_token)
except OidcProviderNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Unknown OIDC provider: " + provider_id,
) from None
except InvalidJWTError as exc:
logger.exception(exc.args)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc

credentials = {
"mechanism": mechanism,
"username": oidc_username,
"token": config.authentication.oidc_icat_authenticator_token,
}

try:
icat_session_id = ICATAuthenticator.authenticate(config.authentication.oidc_icat_authenticator, credentials)
icat_username = ICATAuthenticator.get_username(icat_session_id)
except ICATAuthenticationError as exc:
logger.exception(exc.args)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc

access_token = jwt_handler.get_access_token(icat_session_id, icat_username)
refresh_token = jwt_handler.get_refresh_token(icat_username)

response = JSONResponse(content=access_token)
response.set_cookie(
key="scigateway:refresh_token",
value=refresh_token,
max_age=config.authentication.refresh_token_validity_days * 24 * 60 * 60,
secure=True,
httponly=True,
samesite="lax",
path=f"{config.api.root_path}/refresh",
)
return response


@router.post(
path="/refresh",
Expand Down
19 changes: 8 additions & 11 deletions scigateway_auth/src/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from scigateway_auth.common.config import config
from scigateway_auth.common.exceptions import ICATAuthenticationError
from scigateway_auth.common.schemas import UserCredentialsPostRequestSchema

logger = logging.getLogger()

Expand All @@ -21,7 +20,7 @@ class ICATAuthenticator:
"""

@staticmethod
def authenticate(mnemonic: str, credentials: UserCredentialsPostRequestSchema = None) -> str:
def authenticate(mnemonic: str, credentials: dict[str, str] | None = None) -> str:
"""
Sends an authentication request to the ICAT authenticator and returns a session ID.

Expand All @@ -32,17 +31,15 @@ def authenticate(mnemonic: str, credentials: UserCredentialsPostRequestSchema =
:return: The ICAT session ID.
"""
logger.info("Authenticating at %s with mnemonic: %s", config.icat_server.url, mnemonic)
json_payload = (
{"plugin": "anon"}
if credentials is None
else {

if credentials is None:
json_payload = {"plugin": "anon"}
else:
json_payload = {
"plugin": mnemonic,
"credentials": [
{"username": credentials.username.get_secret_value()},
{"password": credentials.password.get_secret_value()},
],
"credentials": [{k: v} for k, v in credentials.items()], # ICAT requires this to be an array of objects
}
)

data = {"json": json.dumps(json_payload)}

response = requests.post(
Expand Down
Loading