-
Notifications
You must be signed in to change notification settings - Fork 2
Add OIDC login #154
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
Add OIDC login #154
Changes from all commits
e7f388c
cc03143
0cd2ef5
79c49de
447aff0
efaf442
e8f5998
f51d8ec
df6f743
4656d08
dbaf6a1
35d716c
529514a
9a8768f
eeaf1aa
13cf647
0b4d50a
4f961a4
f5ee06c
7d0c8e7
b32a6f0
f79eb57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||||||
|
|
||||||
|
|
||||||
|
|
@@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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. | ||||||
|
|
@@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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): | ||||||
| """ | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
|
@@ -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", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be used for secrets.