Add refresh token button
All checks were successful
/ build (push) Successful in 5s
/ test (push) Successful in 5s

This commit is contained in:
phil 2025-02-08 18:32:02 +01:00
parent ff72f0cae5
commit 923a63f5d5
6 changed files with 58 additions and 24 deletions

View file

@ -13,7 +13,7 @@ from httpx import AsyncClient
from authlib.oauth2.auth import OAuth2Token from authlib.oauth2.auth import OAuth2Token
from .models import User from .models import User
from .database import TokenNotInDb, db, UserNotInDB from .database import db, TokenNotInDb, UserNotInDB
from .settings import oidc_providers_settings from .settings import oidc_providers_settings
logger = logging.getLogger("oidc-test") logger = logging.getLogger("oidc-test")
@ -36,14 +36,14 @@ async def fetch_token(name, request):
async def update_token(name, token, refresh_token=None, access_token=None): async def update_token(name, token, refresh_token=None, access_token=None):
"""Update the token in the database"""
oidc_provider_settings = oidc_providers_settings[name] oidc_provider_settings = oidc_providers_settings[name]
sid: str = oidc_provider_settings.decode(token["id_token"])["sid"] sid: str = oidc_provider_settings.decode(token["id_token"])["sid"]
item = await db.get_token(oidc_provider_settings, sid) item = await db.get_token(oidc_provider_settings, sid)
# update old token # update old token
if access_token is not None: item["access_token"] = token["access_token"]
item["access_token"] = token.get("access_token") item["refresh_token"] = token["refresh_token"]
if refresh_token is not None: item["id_token"] = token["id_token"]
item["refresh_token"] = refresh_token
item["expires_at"] = token["expires_at"] item["expires_at"] = token["expires_at"]
logger.info(f"Token {sid} refreshed") logger.info(f"Token {sid} refreshed")
# It's a fake db and only in memory, so there's nothing to save # It's a fake db and only in memory, so there's nothing to save
@ -70,8 +70,8 @@ def init_providers():
api_base_url=provider.url, api_base_url=provider.url,
# For PKCE (not implemented yet): # For PKCE (not implemented yet):
# code_challenge_method="S256", # code_challenge_method="S256",
# fetch_token=fetch_token, fetch_token=fetch_token,
# update_token=update_token, 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) # client_id="some-client-id", # if enabled, authlib will also check that the access token belongs to this client id (audience)
) )
@ -101,7 +101,10 @@ def get_oidc_provider_or_none(request: Request) -> StarletteOAuth2App | None:
def get_oidc_provider(request: Request) -> StarletteOAuth2App: def get_oidc_provider(request: Request) -> StarletteOAuth2App:
if (oidc_provider := get_oidc_provider_or_none(request)) is None: if (oidc_provider := get_oidc_provider_or_none(request)) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider") if oidc_provider is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No provider")
else:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No such provider")
else: else:
return oidc_provider return oidc_provider

View file

@ -35,6 +35,8 @@ from .auth_utils import (
authlib_oauth, authlib_oauth,
get_providers_info, get_providers_info,
get_token_or_none, get_token_or_none,
get_token,
update_token,
) )
from .auth_misc import pretty_details from .auth_misc import pretty_details
from .database import TokenNotInDb, db from .database import TokenNotInDb, db
@ -97,7 +99,7 @@ async def home(
access_token_scope = None access_token_scope = None
else: else:
try: try:
access_token_scope = user.decode_access_token()["scope"] access_token_scope = user.get_scope(verify_signature=False)
except InvalidTokenError as err: except InvalidTokenError as err:
access_token_scope = None access_token_scope = None
logger.info("Invalid token") logger.info("Invalid token")
@ -113,15 +115,22 @@ async def home(
"resources": resources, "resources": resources,
} }
if token is None: if token is None:
context["access_token"] = None
context["id_token_parsed"] = None context["id_token_parsed"] = None
context["access_token_parsed"] = None context["access_token_parsed"] = None
context["refresh_token_parsed"] = None context["refresh_token_parsed"] = None
else: else:
context["access_token"] = token["access_token"]
assert oidc_provider is not None assert oidc_provider is not None
assert oidc_provider.name is not None assert oidc_provider.name is not None
oidc_provider_settings = oidc_providers_settings[oidc_provider.name] oidc_provider_settings = oidc_providers_settings[oidc_provider.name]
context["id_token_parsed"] = pretty_details(user, now) # context["id_token_parsed"] = pretty_details(user, now)
context["access_token_parsed"] = oidc_provider_settings.decode(token["access_token"]) context["id_token_parsed"] = oidc_provider_settings.decode(
token["id_token"], verify_signature=False
)
context["access_token_parsed"] = oidc_provider_settings.decode(
token["access_token"], verify_signature=False
)
context["refresh_token_parsed"] = oidc_provider_settings.decode( context["refresh_token_parsed"] = oidc_provider_settings.decode(
token["refresh_token"], verify_signature=False token["refresh_token"], verify_signature=False
) )
@ -282,6 +291,21 @@ async def non_compliant_logout(
) )
@app.get("/refresh")
async def refresh(
request: Request,
oidc_provider: Annotated[StarletteOAuth2App, Depends(get_oidc_provider)],
token: Annotated[OAuth2Token, Depends(get_token)],
) -> RedirectResponse:
"""Manually refresh token"""
new_token = await oidc_provider.fetch_access_token(
refresh_token=token["refresh_token"],
grant_type="refresh_token",
)
await update_token(oidc_provider.name, new_token)
return RedirectResponse(url=request.url_for("home"))
# Snippet for running standalone # Snippet for running standalone
# Mostly useful for the --version option, # Mostly useful for the --version option,
# as running with uvicorn is easy and provides better flexibility, eg. # as running with uvicorn is easy and provides better flexibility, eg.

View file

@ -54,10 +54,15 @@ class User(UserBase):
access_token_scopes = [] access_token_scopes = []
return scope in set(info_scopes + access_token_scopes) return scope in set(info_scopes + access_token_scopes)
def decode_access_token(self): def decode_access_token(self, verify_signature: bool = True):
assert self.access_token is not None assert self.access_token is not None
assert self.oidc_provider is not None assert self.oidc_provider is not None
assert self.oidc_provider.name is not None assert self.oidc_provider.name is not None
from .settings import oidc_providers_settings from .settings import oidc_providers_settings
return oidc_providers_settings[self.oidc_provider.name].decode(self.access_token) return oidc_providers_settings[self.oidc_provider.name].decode(
self.access_token, verify_signature=verify_signature
)
def get_scope(self, verify_signature: bool = True):
return self.decode_access_token(verify_signature=verify_signature)["scope"]

View file

@ -2,6 +2,7 @@ async function checkHref(elem, token, authProvider) {
const msg = document.getElementById("msg") const msg = document.getElementById("msg")
const url = `resource/${elem.getAttribute("resource-id")}` const url = `resource/${elem.getAttribute("resource-id")}`
const resp = await fetch(url, { const resp = await fetch(url, {
method: "GET",
headers: new Headers({ headers: new Headers({
"Content-type": "application/json", "Content-type": "application/json",
"Authorization": `Bearer ${token}`, "Authorization": `Bearer ${token}`,

View file

@ -4,7 +4,7 @@
<link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
<script src="{{ url_for('static', path='/utils.js') }}"></script> <script src="{{ url_for('static', path='/utils.js') }}"></script>
</head> </head>
<body onload="checkPerms('links-to-check', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')"> <body onload="checkPerms('links-to-check', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">
<h1>OIDC-test - FastAPI client</h1> <h1>OIDC-test - FastAPI client</h1>
{% block content %} {% block content %}
{% endblock %} {% endblock %}

View file

@ -57,6 +57,7 @@
Account management Account management
</button> </button>
{% endif %} {% endif %}
<button onclick="location.href='{{ request.url_for("refresh") }}'" class="refresh">Refresh</button>
<button onclick="location.href='{{ request.url_for("logout") }}'" class="logout">Logout</button> <button onclick="location.href='{{ request.url_for("logout") }}'" class="logout">Logout</button>
</div> </div>
{% endif %} {% endif %}
@ -66,21 +67,21 @@
Resources validated by scope: Resources validated by scope:
</p> </p>
<div class="links-to-check"> <div class="links-to-check">
<button resource-id="time" onclick="get_resource('time', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Time</button> <button resource-id="time" onclick="get_resource('time', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Time</button>
<button resource-id="bs" onclick="get_resource('bs', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">BS</button> <button resource-id="bs" onclick="get_resource('bs', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">BS</button>
</div> </div>
<p> <p>
Resources validated by role: Resources validated by role:
</p> </p>
<div class="links-to-check"> <div class="links-to-check">
<button resource-id="public" onclick="get_resource('public', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Public</button> <button resource-id="public" onclick="get_resource('public', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Public</button>
<button resource-id="protected" onclick="get_resource('protected', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Auth protected content</button> <button resource-id="protected" onclick="get_resource('protected', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Auth protected content</button>
<button resource-id="protected-by-foorole" onclick="get_resource('protected-by-foorole', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Auth + foorole protected content</button> <button resource-id="protected-by-foorole" onclick="get_resource('protected-by-foorole', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Auth + foorole protected content</button>
<button resource-id="protected-by-foorole-or-barrole" onclick="get_resource('protected-by-foorole-or-barrole', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Auth + foorole or barrole protected content</button> <button resource-id="protected-by-foorole-or-barrole" onclick="get_resource('protected-by-foorole-or-barrole', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Auth + foorole or barrole protected content</button>
<button resource-id="protected-by-barrole" onclick="get_resource('protected-by-barrole', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Auth + barrole protected content</button> <button resource-id="protected-by-barrole" onclick="get_resource('protected-by-barrole', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Auth + barrole protected content</button>
<button resource-id="protected-by-foorole-and-barrole" onclick="get_resource('protected-by-foorole-and-barrole', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Auth + foorole and barrole protected content</button> <button resource-id="protected-by-foorole-and-barrole" onclick="get_resource('protected-by-foorole-and-barrole', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Auth + foorole and barrole protected content</button>
<button resource-id="fast_api_depends" class="hidden" onclick="get_resource('fast_api_depends', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Using FastAPI Depends</button> <button resource-id="fast_api_depends" class="hidden" onclick="get_resource('fast_api_depends', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Using FastAPI Depends</button>
<!--<button resource-id="introspect" onclick="get_resource('introspect', '{{ user.access_token }}', '{{ oidc_provider_settings.id }}')">Introspect token (401 expected)</button>--> <!--<button resource-id="introspect" onclick="get_resource('introspect', '{{ access_token }}', '{{ oidc_provider_settings.id }}')">Introspect token (401 expected)</button>-->
</div> </div>
<div class="resourceResult"> <div class="resourceResult">
<div id="resource" class="resource"></div> <div id="resource" class="resource"></div>