List of resources for OIDC providers
Some checks failed
/ build (push) Failing after 1m8s
/ test (push) Successful in 5s

This commit is contained in:
phil 2025-01-19 14:26:54 +01:00
parent f14d8d3114
commit 54345dcafd
6 changed files with 74 additions and 32 deletions

View file

@ -19,18 +19,22 @@ logger = logging.getLogger(__name__)
OIDC_PROVIDERS = set([provider.id for provider in settings.oidc.providers])
def get_oidc_provider(request: Request) -> StarletteOAuth2App:
def get_oidc_provider_or_none(request: Request) -> StarletteOAuth2App | None:
"""Return the oidc_provider from a request object, from the session.
It can be used in Depends()"""
if (oidc_provider_id := request.session.get("oidc_provider_id")) is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Not logged in (no provider in session)",
)
return
try:
return getattr(authlib_oauth, str(oidc_provider_id))
except AttributeError:
return
def get_oidc_provider(request: Request) -> StarletteOAuth2App:
if (oidc_provider := get_oidc_provider_or_none(request)) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
else:
return oidc_provider
async def get_current_user(request: Request) -> User:

View file

@ -21,10 +21,11 @@ from authlib.integrations.httpx_client import AsyncOAuth2Client
from authlib.oauth2.rfc6749 import OAuth2Token
from pkce import generate_code_verifier, generate_pkce_pair
from .settings import settings
from .settings import settings, OIDCProvider
from .models import User
from .auth_utils import (
get_oidc_provider,
get_oidc_provider_or_none,
hasrole,
get_current_user_or_none,
get_current_user,
@ -55,8 +56,8 @@ app.add_middleware(
# Add oidc providers to authlib from the settings
fastapi_providers = {}
_providers = {}
# fastapi_providers: dict[str, OpenIdConnect] = {}
providers_settings: dict[str, OIDCProvider] = {}
for provider in settings.oidc.providers:
authlib_oauth.register(
@ -74,17 +75,27 @@ for provider in settings.oidc.providers:
# update_token=update_token,
# client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
)
fastapi_providers[provider.id] = OpenIdConnect(
openIdConnectUrl=provider.openid_configuration
)
_providers[provider.id] = provider
# fastapi_providers[provider.id] = OpenIdConnect(
# openIdConnectUrl=provider.openid_configuration
# )
providers_settings[provider.id] = provider
@app.get("/")
async def home(
request: Request, user: Annotated[User, Depends(get_current_user_or_none)]
request: Request,
user: Annotated[User, Depends(get_current_user_or_none)],
oidc_provider: Annotated[
StarletteOAuth2App | None, Depends(get_oidc_provider_or_none)
],
) -> HTMLResponse:
now = datetime.now()
if oidc_provider and (
(provider := providers_settings.get(oidc_provider.name)) is not None
):
resources = provider.resources
else:
resources = []
return templates.TemplateResponse(
name="home.html",
request=request,
@ -92,6 +103,7 @@ async def home(
"settings": settings.model_dump(),
"user": user,
"now": now,
"resources": resources,
"user_info_details": (
pretty_details(user, now)
if user and settings.oidc.show_session_details
@ -112,11 +124,13 @@ async def login(request: Request, oidc_provider_id: str) -> RedirectResponse:
"""
redirect_uri = request.url_for("auth", oidc_provider_id=oidc_provider_id)
try:
provider_: StarletteOAuth2App = getattr(authlib_oauth, oidc_provider_id)
provider: StarletteOAuth2App = getattr(authlib_oauth, oidc_provider_id)
except AttributeError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
if (
code_challenge_method := _providers[oidc_provider_id].code_challenge_method
code_challenge_method := providers_settings[
oidc_provider_id
].code_challenge_method
) is not None:
client = AsyncOAuth2Client(..., code_challenge_method=code_challenge_method)
code_verifier = generate_code_verifier()
@ -124,7 +138,7 @@ async def login(request: Request, oidc_provider_id: str) -> RedirectResponse:
else:
code_verifier = None
try:
response = await provider_.authorize_redirect(
response = await provider.authorize_redirect(
request,
redirect_uri,
access_type="offline",
@ -238,26 +252,37 @@ async def non_compliant_logout(
# Route for OAuth resource server
@app.get("/resource/{name}")
@app.get("/resource/{id}")
async def get_resource(
name: str,
id: str,
request: Request,
user: Annotated[User, Depends(get_current_user)],
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
token: Annotated[OAuth2Token, Depends(get_token)],
) -> HTMLResponse:
) -> JSONResponse:
"""Generic path for testing a resource provided by a provider"""
provider = _providers[oidc_provider.name]
if oidc_provider is None:
raise HTTPException(
status.HTTP_406_NOT_ACCEPTABLE, detail="No such oidc provider"
)
if (provider := providers_settings.get(oidc_provider.name)) is None:
raise HTTPException(
status.HTTP_406_NOT_ACCEPTABLE, detail="No oidc provider setting"
)
try:
resource = next(x for x in provider.resources if x.id == id)
except StopIteration:
raise HTTPException(
status.HTTP_406_NOT_ACCEPTABLE, detail="No such resource for this provider"
)
if (
response := await oidc_provider.get(
"/api/v1/user/repos",
resource.url,
# headers={"Authorization": f"token {token['access_token']}"},
token=token,
)
).is_success:
repos = response.json()
names = [repo["name"] for repo in repos]
return HTMLResponse(f"{user.name} has {len(repos)} repos: {', '.join(names)}")
return JSONResponse(response.json())
else:
raise HTTPException(status_code=response.status_code, detail=response.text)

View file

@ -16,6 +16,7 @@ from pydantic_settings import (
class Resource(BaseModel):
"""A resource with an URL that can be accessed with an OAuth2 access token"""
id: str
name: str
url: str

View file

@ -57,7 +57,7 @@ hr {
.user-info a.logout:hover {
background-color: orange;
}
debug-auth {
.debug-auth {
font-size: 90%;
background-color: #d8bebc75;
padding: 6px;
@ -152,14 +152,14 @@ debug-auth {
font-weight: bold;
flex: 1 1 auto;
}
.content #links-to-check {
.content .links-to-check {
display: flex;
text-align: center;
justify-content: center;
gap: 0.5em;
flex-flow: wrap;
}
.content #links-to-check a {
.content .links-to-check a {
color: black;
padding: 5px 10px;
text-decoration: none;

View file

@ -11,7 +11,9 @@ function checkHref(elem) {
xmlHttp.send(null)
}
function checkPerms(rootId) {
var rootElem = document.getElementById(rootId)
Array.from(rootElem.children).forEach(elem => checkHref(elem))
function checkPerms(className) {
var rootElems = document.getElementsByClassName(className)
Array.from(rootElems).forEach(elem =>
Array.from(elem.children).forEach(elem => checkHref(elem))
)
}

View file

@ -50,7 +50,7 @@
<p>
These links should get different response codes depending on the authorization:
</p>
<div id="links-to-check">
<div class="links-to-check">
<a href="public">Public</a>
<a href="protected">Auth protected content</a>
<a href="protected-by-foorole">Auth + foorole protected content</a>
@ -62,6 +62,16 @@
<a href="oauth2-forgejo-test">OAuth2 test (forgejo user info)</a>
<a href="introspect">Introspect token (401 expected)</a>
</div>
{% if resources %}
<p>
Resources for this provider:
</p>
<div class="links-to-check">
{% for resource in resources %}
<a href="{{ request.url_for("get_resource", id=resource.id) }}">{{ resource.name }}</a>
{% endfor %}
</div>
{% endif %}
{% if user_info_details %}
<hr>
<div class="debug-auth">
@ -74,7 +84,7 @@
{% endfor %}
</ul>
</div>
<div>Now is: {{ now }}</div>
<div>Now is: {{ now.strftime("%T, %D") }} </div>
</div>
{% endif %}
{% endblock %}