Refactor; add services in settings
This commit is contained in:
parent
17fabd21c9
commit
f14d8d3114
7 changed files with 272 additions and 224 deletions
|
@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||
OIDC_PROVIDERS = set([provider.id for provider in settings.oidc.providers])
|
||||
|
||||
|
||||
def get_provider(request: Request) -> StarletteOAuth2App:
|
||||
def get_oidc_provider(request: Request) -> StarletteOAuth2App:
|
||||
"""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:
|
||||
|
@ -45,7 +45,7 @@ async def get_current_user(request: Request) -> User:
|
|||
user = await db.get_user(user_sub)
|
||||
## Check if the token is expired
|
||||
if token.is_expired():
|
||||
oidc_provider = get_provider(request=request)
|
||||
oidc_provider = get_oidc_provider(request=request)
|
||||
## Ask a new refresh token from the provider
|
||||
logger.info(f"Token expired for user {user.name}")
|
||||
try:
|
||||
|
@ -61,7 +61,17 @@ async def get_current_user(request: Request) -> User:
|
|||
return user
|
||||
|
||||
|
||||
async def get_token(request: Request) -> OAuth2Token:
|
||||
"""Return the token from a request object, from the session.
|
||||
It can be used in Depends()"""
|
||||
if (token := await db.get_token(request.session.get("token"))) is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
|
||||
return token
|
||||
|
||||
|
||||
async def get_current_user_or_none(request: Request) -> User | None:
|
||||
"""Return the user from a request object, from the session.
|
||||
It can be used in Depends()"""
|
||||
try:
|
||||
return await get_current_user(request)
|
||||
except HTTPException:
|
||||
|
@ -69,6 +79,7 @@ async def get_current_user_or_none(request: Request) -> User | None:
|
|||
|
||||
|
||||
def hasrole(required_roles: Union[str, list[str]] = []):
|
||||
"""Decorator for RBAC permissions"""
|
||||
required_roles_set: set[str]
|
||||
if isinstance(required_roles, str):
|
||||
required_roles_set = set([required_roles])
|
||||
|
@ -118,10 +129,4 @@ def update_token(*args, **kwargs):
|
|||
...
|
||||
|
||||
|
||||
async def get_token(request: Request) -> OAuth2Token:
|
||||
if (token := await db.get_token(request.session.get("token"))) is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No token")
|
||||
return token
|
||||
|
||||
|
||||
authlib_oauth = OAuth(cache=None, fetch_token=fetch_token, update_token=update_token)
|
||||
|
|
|
@ -10,6 +10,7 @@ from urllib.parse import urlencode
|
|||
|
||||
from httpx import HTTPError
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.security import OpenIdConnect
|
||||
|
@ -23,7 +24,7 @@ from pkce import generate_code_verifier, generate_pkce_pair
|
|||
from .settings import settings
|
||||
from .models import User
|
||||
from .auth_utils import (
|
||||
get_provider,
|
||||
get_oidc_provider,
|
||||
hasrole,
|
||||
get_current_user_or_none,
|
||||
get_current_user,
|
||||
|
@ -42,6 +43,9 @@ app = FastAPI(
|
|||
title="OIDC auth test",
|
||||
)
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static"
|
||||
)
|
||||
|
||||
# SessionMiddleware is required by authlib
|
||||
app.add_middleware(
|
||||
|
@ -76,6 +80,27 @@ for provider in settings.oidc.providers:
|
|||
_providers[provider.id] = provider
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def home(
|
||||
request: Request, user: Annotated[User, Depends(get_current_user_or_none)]
|
||||
) -> HTMLResponse:
|
||||
now = datetime.now()
|
||||
return templates.TemplateResponse(
|
||||
name="home.html",
|
||||
request=request,
|
||||
context={
|
||||
"settings": settings.model_dump(),
|
||||
"user": user,
|
||||
"now": now,
|
||||
"user_info_details": (
|
||||
pretty_details(user, now)
|
||||
if user and settings.oidc.show_session_details
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Endpoints for the login / authorization process
|
||||
|
||||
|
||||
|
@ -169,13 +194,13 @@ async def auth(request: Request, oidc_provider_id: str) -> RedirectResponse:
|
|||
@app.get("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
provider: Annotated[StarletteOAuth2App, Depends(get_provider)],
|
||||
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
|
||||
) -> RedirectResponse:
|
||||
# Clear session
|
||||
request.session.pop("user_sub", None)
|
||||
# Get provider's endpoint
|
||||
if (
|
||||
provider_logout_uri := provider.server_metadata.get("end_session_endpoint")
|
||||
provider_logout_uri := oidc_provider.server_metadata.get("end_session_endpoint")
|
||||
) is None:
|
||||
logger.warn(f"Cannot find end_session_endpoint for provider {provider.name}")
|
||||
return RedirectResponse(request.url_for("non_compliant_logout"))
|
||||
|
@ -200,7 +225,7 @@ async def logout(
|
|||
@app.get("/non-compliant-logout")
|
||||
async def non_compliant_logout(
|
||||
request: Request,
|
||||
provider: Annotated[StarletteOAuth2App, Depends(get_provider)],
|
||||
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
|
||||
):
|
||||
"""A page for non-compliant OAuth2 servers that we cannot log out."""
|
||||
return templates.TemplateResponse(
|
||||
|
@ -210,28 +235,34 @@ async def non_compliant_logout(
|
|||
)
|
||||
|
||||
|
||||
# Home URL
|
||||
# Route for OAuth resource server
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def home(
|
||||
request: Request, user: Annotated[User, Depends(get_current_user_or_none)]
|
||||
@app.get("/resource/{name}")
|
||||
async def get_resource(
|
||||
name: 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:
|
||||
now = datetime.now()
|
||||
return templates.TemplateResponse(
|
||||
name="home.html",
|
||||
request=request,
|
||||
context={
|
||||
"settings": settings.model_dump(),
|
||||
"user": user,
|
||||
"now": now,
|
||||
"user_info_details": (
|
||||
pretty_details(user, now)
|
||||
if user and settings.oidc.show_session_details
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
"""Generic path for testing a resource provided by a provider"""
|
||||
provider = _providers[oidc_provider.name]
|
||||
if (
|
||||
response := await oidc_provider.get(
|
||||
"/api/v1/user/repos",
|
||||
# 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)}")
|
||||
else:
|
||||
raise HTTPException(status_code=response.status_code, detail=response.text)
|
||||
|
||||
|
||||
# Routes for test
|
||||
|
||||
|
||||
@app.get("/public")
|
||||
|
@ -239,9 +270,6 @@ async def public() -> HTMLResponse:
|
|||
return HTMLResponse("<h1>Not protected</h1>")
|
||||
|
||||
|
||||
# Some URIs for testing the permissions
|
||||
|
||||
|
||||
@app.get("/protected")
|
||||
async def get_protected(
|
||||
user: Annotated[User, Depends(get_current_user)]
|
||||
|
@ -277,12 +305,12 @@ async def get_protected_by_foorole_or_barrole(request: Request) -> HTMLResponse:
|
|||
@app.get("/introspect")
|
||||
async def get_introspect(
|
||||
request: Request,
|
||||
provider: Annotated[StarletteOAuth2App, Depends(get_provider)],
|
||||
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
|
||||
token: Annotated[OAuth2Token, Depends(get_token)],
|
||||
) -> JSONResponse:
|
||||
if (
|
||||
response := await provider.post(
|
||||
provider.server_metadata["introspection_endpoint"],
|
||||
response := await oidc_provider.post(
|
||||
oidc_provider.server_metadata["introspection_endpoint"],
|
||||
token=token,
|
||||
data={"token": token["access_token"]},
|
||||
)
|
||||
|
@ -296,11 +324,11 @@ async def get_introspect(
|
|||
async def get_forgejo_user_info(
|
||||
request: Request,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
provider: Annotated[StarletteOAuth2App, Depends(get_provider)],
|
||||
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
|
||||
token: Annotated[OAuth2Token, Depends(get_token)],
|
||||
) -> HTMLResponse:
|
||||
if (
|
||||
response := await provider.get(
|
||||
response := await oidc_provider.get(
|
||||
"/api/v1/user/repos",
|
||||
# headers={"Authorization": f"token {token['access_token']}"},
|
||||
token=token,
|
||||
|
@ -313,11 +341,9 @@ async def get_forgejo_user_info(
|
|||
raise HTTPException(status_code=response.status_code, detail=response.text)
|
||||
|
||||
|
||||
# @app.get("/fast_api_depends")
|
||||
# def fast_api_depends(
|
||||
# token: Annotated[str, Depends(fastapi_providers["Keycloak"])]
|
||||
# ) -> HTMLResponse:
|
||||
# return HTMLResponse("You're Authenticated")
|
||||
# Snippet for running standalone
|
||||
# Mostly useful for the --version option,
|
||||
# as running with uvicorn is easy and provides flaxibility
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
@ -13,7 +13,16 @@ from pydantic_settings import (
|
|||
)
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""A resource with an URL that can be accessed with an OAuth2 access token"""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class OIDCProvider(BaseModel):
|
||||
"""OIDC provider, can also be a resource server"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
|
@ -22,6 +31,7 @@ class OIDCProvider(BaseModel):
|
|||
# For PKCE (not implemented yet)
|
||||
code_challenge_method: str | None = None
|
||||
hint: str = "No hint"
|
||||
resources: list[Resource] = []
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
|
|
168
src/oidc_test/static/styles.css
Normal file
168
src/oidc_test/static/styles.css
Normal file
|
@ -0,0 +1,168 @@
|
|||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: floralwhite;
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
background-color: #f7c7867d;
|
||||
margin: 0 0 0.2em 0;
|
||||
}
|
||||
p {
|
||||
margin: 0.2em;
|
||||
}
|
||||
hr {
|
||||
margin: 0.2em;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-info {
|
||||
padding: 0.5em;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
margin: 5px auto;
|
||||
box-shadow: 0px 0px 10px lightgreen;
|
||||
background-color: lightgreen;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.user-info * {
|
||||
flex: 2 1 auto;
|
||||
margin: 0;
|
||||
}
|
||||
.user-info .picture {
|
||||
max-width: 3em;
|
||||
max-height: 3em
|
||||
}
|
||||
.user-info a.logout {
|
||||
border: 2px solid darkkhaki;
|
||||
padding: 3px 6px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
color: black;
|
||||
}
|
||||
.user-info a.logout:hover {
|
||||
background-color: orange;
|
||||
}
|
||||
debug-auth {
|
||||
font-size: 90%;
|
||||
background-color: #d8bebc75;
|
||||
padding: 6px;
|
||||
}
|
||||
.debug-auth * {
|
||||
margin: 0;
|
||||
}
|
||||
.debug-auth p {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
.debug-auth ul {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.debug-auth p, .debug-auth .key {
|
||||
font-weight: bold;
|
||||
}
|
||||
.content {
|
||||
text-align: left;
|
||||
}
|
||||
.hasResponseStatus {
|
||||
background-color: #88888840;
|
||||
}
|
||||
.hasResponseStatus.status-200 {
|
||||
background-color: #00ff0040;
|
||||
}
|
||||
.hasResponseStatus.status-401 {
|
||||
background-color: #ff000040;
|
||||
}
|
||||
.hasResponseStatus.status-403 {
|
||||
background-color: #ff990040;
|
||||
}
|
||||
.hasResponseStatus.status-404 {
|
||||
background-color: #ffCC0040;
|
||||
}
|
||||
.hasResponseStatus.status-503 {
|
||||
background-color: #ffA88050;
|
||||
}
|
||||
.role {
|
||||
padding: 3px 6px;
|
||||
background-color: #44228840;
|
||||
}
|
||||
|
||||
/* For home */
|
||||
|
||||
.login-box {
|
||||
text-align: center;
|
||||
background-color: antiquewhite;
|
||||
margin: 0.5em auto;
|
||||
width: fit-content;
|
||||
box-shadow: 0 0 10px #49759b88;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.login-box .description {
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
background-color: #f7c7867d;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.providers {
|
||||
justify-content: center;
|
||||
padding: 0.8em;
|
||||
}
|
||||
.providers .provider {
|
||||
min-height: 2em;
|
||||
}
|
||||
.providers .provider a.link {
|
||||
text-decoration: none;
|
||||
max-height: 2em;
|
||||
}
|
||||
.providers .provider .link div {
|
||||
text-align: center;
|
||||
background-color: #f7c7867d;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
.providers .provider .hint {
|
||||
font-size: 80%;
|
||||
max-width: 13em;
|
||||
}
|
||||
.providers .error {
|
||||
color: darkred;
|
||||
padding: 3px 6px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.content #links-to-check {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
flex-flow: wrap;
|
||||
}
|
||||
.content #links-to-check a {
|
||||
color: black;
|
||||
padding: 5px 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
17
src/oidc_test/static/utils.js
Normal file
17
src/oidc_test/static/utils.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
function checkHref(elem) {
|
||||
var xmlHttp = new XMLHttpRequest()
|
||||
xmlHttp.onreadystatechange = function () {
|
||||
if (xmlHttp.readyState == 4) {
|
||||
elem.classList.add("hasResponseStatus")
|
||||
elem.classList.add("status-" + xmlHttp.status)
|
||||
elem.title = "Response code: " + xmlHttp.status + " - " + xmlHttp.statusText
|
||||
}
|
||||
}
|
||||
xmlHttp.open("GET", elem.href, true) // true for asynchronous
|
||||
xmlHttp.send(null)
|
||||
}
|
||||
|
||||
function checkPerms(rootId) {
|
||||
var rootElem = document.getElementById(rootId)
|
||||
Array.from(rootElem.children).forEach(elem => checkHref(elem))
|
||||
}
|
|
@ -1,136 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>FastAPI OIDC test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: floralwhite;
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
background-color: #f7c7867d;
|
||||
margin: 0 0 0.2em 0;
|
||||
}
|
||||
p {
|
||||
margin: 0.2em;
|
||||
}
|
||||
hr {
|
||||
margin: 0.2em;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-info {
|
||||
padding: 0.5em;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
margin: 5px auto;
|
||||
box-shadow: 0px 0px 10px lightgreen;
|
||||
background-color: lightgreen;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.user-info * {
|
||||
flex: 2 1 auto;
|
||||
margin: 0;
|
||||
}
|
||||
.user-info .picture {
|
||||
max-width: 3em;
|
||||
max-height: 3em
|
||||
}
|
||||
.user-info a.logout {
|
||||
border: 2px solid darkkhaki;
|
||||
padding: 3px 6px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
color: black;
|
||||
}
|
||||
.user-info a.logout:hover {
|
||||
background-color: orange;
|
||||
}
|
||||
.debug-auth {
|
||||
font-size: 90%;
|
||||
background-color: #d8bebc75;
|
||||
padding: 6px;
|
||||
}
|
||||
.debug-auth * {
|
||||
margin: 0;
|
||||
}
|
||||
.debug-auth p {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
.debug-auth ul {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.debug-auth p, .debug-auth .key {
|
||||
font-weight: bold;
|
||||
}
|
||||
.content {
|
||||
text-align: left;
|
||||
}
|
||||
.content #links-to-check {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
flex-flow: wrap;
|
||||
}
|
||||
.content #links-to-check a {
|
||||
color: black;
|
||||
padding: 5px 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hasResponseStatus {
|
||||
background-color: #88888840;
|
||||
}
|
||||
.hasResponseStatus.status-200 {
|
||||
background-color: #00ff0040;
|
||||
}
|
||||
.hasResponseStatus.status-401 {
|
||||
background-color: #ff000040;
|
||||
}
|
||||
.hasResponseStatus.status-403 {
|
||||
background-color: #ff990040;
|
||||
}
|
||||
.role {
|
||||
padding: 3px 6px;
|
||||
background-color: #44228840;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function checkHref(elem) {
|
||||
var xmlHttp = new XMLHttpRequest()
|
||||
xmlHttp.onreadystatechange = function() {
|
||||
if (xmlHttp.readyState == 4) {
|
||||
elem.classList.add("hasResponseStatus")
|
||||
elem.classList.add("status-" + xmlHttp.status)
|
||||
elem.title = "Response code: " + xmlHttp.status + " - " + xmlHttp.statusText
|
||||
}
|
||||
}
|
||||
xmlHttp.open("GET", elem.href, true) // true for asynchronous
|
||||
xmlHttp.send(null)
|
||||
}
|
||||
function checkPerms(rootId) {
|
||||
var rootElem = document.getElementById(rootId)
|
||||
Array.from(rootElem.children).forEach(elem => checkHref(elem))
|
||||
}
|
||||
</script>
|
||||
<link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
|
||||
<script src="{{ url_for('static', path='/utils.js') }}"></script>
|
||||
</head>
|
||||
<body onload="checkPerms('links-to-check')">
|
||||
<h1>OIDC-test</h1>
|
||||
|
|
|
@ -1,55 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.login-box {
|
||||
text-align: center;
|
||||
background-color: antiquewhite;
|
||||
margin: 0.5em auto;
|
||||
width: fit-content;
|
||||
box-shadow: 0 0 10px #49759b88;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.login-box .description {
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
background-color: #f7c7867d;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.providers {
|
||||
justify-content: center;
|
||||
padding: 0.8em;
|
||||
}
|
||||
.providers .provider {
|
||||
min-height: 2em;
|
||||
}
|
||||
.providers .provider a.link {
|
||||
text-decoration: none;
|
||||
max-height: 2em;
|
||||
}
|
||||
.providers .provider .link div {
|
||||
text-align: center;
|
||||
background-color: #f7c7867d;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
.providers .provider .hint {
|
||||
font-size: 80%;
|
||||
max-width: 13em;
|
||||
}
|
||||
.providers .error {
|
||||
color: darkred;
|
||||
padding: 3px 6px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
||||
<p class="center">
|
||||
Test the authentication and authorization,
|
||||
with OpenID Connect and OAuth2 with different providers.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue