Compare commits
29 commits
Author | SHA1 | Date | |
---|---|---|---|
8b3a339196 | |||
b01f233208 | |||
4355e6dc42 | |||
c3ebad42d5 | |||
c5b1bdeda9 | |||
821df02758 | |||
9f7b090273 | |||
22d0a9852c | |||
6f060dc2bf | |||
f4b38e1c69 | |||
b465394766 | |||
9c46237905 | |||
3da485c945 | |||
9c1f843283 | |||
ef7c265d8e | |||
395ec1c7f7 | |||
9249885c80 | |||
5f429797ff | |||
850db9f590 | |||
f6a84fd3aa | |||
4c2b197850 | |||
347c395394 | |||
3f945310a4 | |||
ecdd3702f8 | |||
d924c56b17 | |||
0764b1c003 | |||
703985f311 | |||
e925f21762 | |||
435c11b6ca |
18 changed files with 334 additions and 133 deletions
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v4
|
uses: astral-sh/setup-uv@v4
|
||||||
with:
|
with:
|
||||||
version: "0.5.16"
|
version: "0.6.9"
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
@ -27,34 +27,26 @@ jobs:
|
||||||
- name: Run tests (API call)
|
- name: Run tests (API call)
|
||||||
run: .venv/bin/pytest -s tests/basic.py
|
run: .venv/bin/pytest -s tests/basic.py
|
||||||
|
|
||||||
- name: Get version with git describe
|
- name: Get version
|
||||||
id: version
|
run: echo "VERSION=$(.venv/bin/dunamai from any --style semver)" >> $GITHUB_ENV
|
||||||
run: |
|
|
||||||
echo "version=$(git describe)" >> $GITHUB_OUTPUT
|
|
||||||
echo "$VERSION"
|
|
||||||
|
|
||||||
- name: Check if the container should be built
|
- name: Version
|
||||||
id: builder
|
run: echo $VERSION
|
||||||
env:
|
|
||||||
RUN: ${{ toJSON(inputs.build || !contains(steps.version.outputs.version, '-')) }}
|
|
||||||
run: |
|
|
||||||
echo "run=$RUN" >> $GITHUB_OUTPUT
|
|
||||||
echo "Run build: $RUN"
|
|
||||||
|
|
||||||
- name: Set the version in pyproject.toml (workaround for uv not supporting dynamic version)
|
- name: Get distance from tag
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
run: echo "DISTANCE=$(.venv/bin/dunamai from any --format '{distance}')" >> $GITHUB_ENV
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
- name: Distance
|
||||||
run: sed "s/0.0.0/$VERSION/" -i pyproject.toml
|
run: echo $DISTANCE
|
||||||
|
|
||||||
- name: Workaround for bug of podman-login
|
- name: Workaround for bug of podman-login
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
if: env.DISTANCE == '0'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p $HOME/.docker
|
mkdir -p $HOME/.docker
|
||||||
echo "{ \"auths\": {} }" > $HOME/.docker/config.json
|
echo "{ \"auths\": {} }" > $HOME/.docker/config.json
|
||||||
|
|
||||||
- name: Log in to the container registry (with another workaround)
|
- name: Log in to the container registry (with another workaround)
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
if: env.DISTANCE == '0'
|
||||||
uses: actions/podman-login@v1
|
uses: actions/podman-login@v1
|
||||||
with:
|
with:
|
||||||
registry: ${{ vars.REGISTRY }}
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
@ -63,30 +55,30 @@ jobs:
|
||||||
auth_file_path: /tmp/auth.json
|
auth_file_path: /tmp/auth.json
|
||||||
|
|
||||||
- name: Build the container image
|
- name: Build the container image
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
if: env.DISTANCE == '0'
|
||||||
uses: actions/buildah-build@v1
|
uses: actions/buildah-build@v1
|
||||||
with:
|
with:
|
||||||
image: oidc-fastapi-test
|
image: oidc-fastapi-test
|
||||||
oci: true
|
oci: true
|
||||||
labels: oidc-fastapi-test
|
labels: oidc-fastapi-test
|
||||||
tags: latest ${{ steps.version.outputs.version }}
|
tags: "latest ${{ env.VERSION }}"
|
||||||
containerfiles: |
|
containerfiles: |
|
||||||
./Containerfile
|
./Containerfile
|
||||||
|
|
||||||
- name: Push the image to the registry
|
- name: Push the image to the registry
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
if: env.DISTANCE == '0'
|
||||||
uses: actions/push-to-registry@v2
|
uses: actions/push-to-registry@v2
|
||||||
with:
|
with:
|
||||||
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
|
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
|
||||||
image: oidc-fastapi-test
|
image: oidc-fastapi-test
|
||||||
tags: latest ${{ steps.version.outputs.version }}
|
tags: "latest ${{ env.VERSION }}"
|
||||||
|
|
||||||
- name: Build wheel
|
- name: Build wheel
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
if: env.DISTANCE == '0'
|
||||||
run: uv build --wheel
|
run: uv build --wheel
|
||||||
|
|
||||||
- name: Publish Python package (home)
|
- name: Publish Python package (home)
|
||||||
if: fromJSON(steps.builder.outputs.run)
|
if: env.DISTANCE == '0'
|
||||||
env:
|
env:
|
||||||
LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
|
LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
|
||||||
run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
|
run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
|
||||||
|
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@v4
|
uses: astral-sh/setup-uv@v4
|
||||||
with:
|
with:
|
||||||
version: "0.5.16"
|
version: "0.6.3"
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM docker.io/library/python:alpine
|
FROM docker.io/library/python:latest
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
|
|
60
README.md
60
README.md
|
@ -52,31 +52,59 @@ given by the OIDC providers.
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
oidc:
|
secret_key: AVeryWellKeptSecret
|
||||||
secret_key: "ASecretNoOneKnows"
|
debug_token: no
|
||||||
show_session_details: yes
|
show_token: yes
|
||||||
|
log: yes
|
||||||
|
|
||||||
|
auth:
|
||||||
providers:
|
providers:
|
||||||
- id: auth0
|
- id: auth0
|
||||||
name: Okta / Auth0
|
name: Okta / Auth0
|
||||||
url: "https://<your_auth0_app_URL>"
|
url: https://<your_auth0_app_URL>
|
||||||
client_id: "<your_auth0_client_id>"
|
public_key_url: https://<your_auth0_app_URL>/pem
|
||||||
client_secret: "client_secret_generated_by_auth0"
|
client_id: <your_auth0_client_id>
|
||||||
hint: "A hint for test credentials"
|
client_secret: client_secret_generated_by_auth0
|
||||||
|
hint: A hint for test credentials
|
||||||
|
|
||||||
- id: keycloak
|
- id: keycloak
|
||||||
name: Keycloak at somewhere
|
name: Keycloak at somewhere
|
||||||
url: "https://<the_keycloak_realm_url>"
|
url: https://<the_keycloak_realm_url>
|
||||||
account_url_template: "/account"
|
info_url: https://philo.ydns.eu/auth/realms/test
|
||||||
client_id: "<your_keycloak_client_id>"
|
account_url_template: /account
|
||||||
client_secret: "client_secret_generated_by_keycloak"
|
client_id: <your_keycloak_client_id>
|
||||||
hint: "User: foo, password: foofoo"
|
client_secret: <client_secret_generated_by_keycloak>
|
||||||
|
hint: A hint for test credentials
|
||||||
|
code_challenge_method: S256
|
||||||
|
resource_provider_scopes:
|
||||||
|
- get:time
|
||||||
|
- get:bs
|
||||||
|
resource_providers:
|
||||||
|
- id: <third_party_resource_provider_id>
|
||||||
|
name: A third party resource provider
|
||||||
|
base_url: https://some.example.com/
|
||||||
|
verify_ssl: yes
|
||||||
|
resources:
|
||||||
|
- name: Public RS2
|
||||||
|
resource_name: public
|
||||||
|
url: resource/public
|
||||||
|
- name: BS RS2
|
||||||
|
resource_name: bs
|
||||||
|
url: resource/bs
|
||||||
|
- name: Time RS2
|
||||||
|
resource_name: time
|
||||||
|
url: resource/time
|
||||||
|
|
||||||
- id: codeberg
|
- id: codeberg
|
||||||
|
disabled: no
|
||||||
name: Codeberg
|
name: Codeberg
|
||||||
url: "https://codeberg.org"
|
url: https://codeberg.org
|
||||||
account_url_template: "/user/settings"
|
account_url_template: /user/settings
|
||||||
client_id: "<your_codeberg_client_id>"
|
client_id: <your_codeberg_client_id>
|
||||||
client_secret: "client_secret_generated_by_codeberg"
|
client_secret: client_secret_generated_by_codeberg
|
||||||
|
info_url: https://codeberg.org/login/oauth/keys
|
||||||
|
session_key: sub
|
||||||
|
skip_verify_signature: no
|
||||||
resources:
|
resources:
|
||||||
- name: List of repos
|
- name: List of repos
|
||||||
id: repos
|
id: repos
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
[project]
|
[project]
|
||||||
name = "oidc-fastapi-test"
|
name = "oidc-fastapi-test"
|
||||||
version = "0.0.0"
|
#version = "0.0.0"
|
||||||
# dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
@ -24,14 +24,21 @@ dependencies = [
|
||||||
oidc-test = "oidc_test.main:main"
|
oidc-test = "oidc_test.main:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["ipdb>=0.13.13", "pytest>=8.3.4"]
|
dev = ["dunamai>=1.23.0", "ipdb>=0.13.13", "pytest>=8.3.4"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling", "uv-dynamic-versioning"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
source = "uv-dynamic-versioning"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/oidc_test"]
|
packages = ["src/oidc_test"]
|
||||||
|
package = true
|
||||||
|
|
||||||
|
[tool.uv-dynamic-versioning]
|
||||||
|
style = "semver"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
package = true
|
package = true
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import importlib.metadata
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dunamai import Version, Style
|
||||||
|
|
||||||
|
__version__ = Version.from_git().serialize(style=Style.SemVer, dirty=True)
|
||||||
|
except ImportError:
|
||||||
|
# __name__ could be used if the package name is the same
|
||||||
|
# as the directory - not the case here
|
||||||
|
# __version__ = importlib.metadata.version(__name__)
|
||||||
|
__version__ = importlib.metadata.version("oidc-fastapi-test")
|
|
@ -7,7 +7,7 @@ from pydantic import ConfigDict
|
||||||
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
|
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from oidc_test.settings import AuthProviderSettings, settings
|
from oidc_test.settings import AuthProviderSettings, ResourceProvider, Resource, settings
|
||||||
from oidc_test.models import User
|
from oidc_test.models import User
|
||||||
|
|
||||||
logger = logging.getLogger("oidc-test")
|
logger = logging.getLogger("oidc-test")
|
||||||
|
@ -24,6 +24,7 @@ class Provider(AuthProviderSettings):
|
||||||
authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
|
authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
|
||||||
info: dict[str, Any] = {}
|
info: dict[str, Any] = {}
|
||||||
unknown_auth_user: User
|
unknown_auth_user: User
|
||||||
|
logout_with_id_token_hint: bool = True
|
||||||
|
|
||||||
def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]:
|
def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]:
|
||||||
"""Decode the token with signature check"""
|
"""Decode the token with signature check"""
|
||||||
|
@ -60,28 +61,34 @@ class Provider(AuthProviderSettings):
|
||||||
if self.info_url is not None:
|
if self.info_url is not None:
|
||||||
try:
|
try:
|
||||||
provider_info = await client.get(self.info_url)
|
provider_info = await client.get(self.info_url)
|
||||||
except Exception:
|
except Exception as err:
|
||||||
|
logger.debug("Provider_info: cannot connect")
|
||||||
|
logger.exception(err)
|
||||||
raise NoPublicKey
|
raise NoPublicKey
|
||||||
try:
|
try:
|
||||||
self.info = provider_info.json()
|
self.info = provider_info.json()
|
||||||
except JSONDecodeError:
|
except JSONDecodeError:
|
||||||
|
logger.debug("Provider_info: cannot decode json response")
|
||||||
raise NoPublicKey
|
raise NoPublicKey
|
||||||
if "public_key" in self.info:
|
if "public_key" in self.info:
|
||||||
# For Keycloak
|
# For Keycloak
|
||||||
try:
|
try:
|
||||||
public_key = str(self.info["public_key"])
|
public_key = str(self.info["public_key"])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
logger.debug("Provider_info: cannot get public_key")
|
||||||
raise NoPublicKey
|
raise NoPublicKey
|
||||||
elif "keys" in self.info:
|
elif "keys" in self.info:
|
||||||
# For Forgejo/Gitea
|
# For Forgejo/Gitea
|
||||||
try:
|
try:
|
||||||
public_key = str(self.info["keys"][0]["n"])
|
public_key = str(self.info["keys"][0]["n"])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
logger.debug("Provider_info: cannot get key 0.n")
|
||||||
raise NoPublicKey
|
raise NoPublicKey
|
||||||
if self.public_key_url is not None:
|
if self.public_key_url is not None:
|
||||||
resp = await client.get(self.public_key_url)
|
resp = await client.get(self.public_key_url)
|
||||||
public_key = resp.text
|
public_key = resp.text
|
||||||
if public_key is None:
|
if public_key is None:
|
||||||
|
logger.debug("Provider_info: cannot determine public key")
|
||||||
raise NoPublicKey
|
raise NoPublicKey
|
||||||
self.public_key = "\n".join(
|
self.public_key = "\n".join(
|
||||||
["-----BEGIN PUBLIC KEY-----", public_key, "-----END PUBLIC KEY-----"]
|
["-----BEGIN PUBLIC KEY-----", public_key, "-----END PUBLIC KEY-----"]
|
||||||
|
@ -89,3 +96,18 @@ class Provider(AuthProviderSettings):
|
||||||
|
|
||||||
def get_session_key(self, userinfo):
|
def get_session_key(self, userinfo):
|
||||||
return userinfo[self.session_key]
|
return userinfo[self.session_key]
|
||||||
|
|
||||||
|
def get_resource(self, resource_name: str) -> Resource:
|
||||||
|
return [
|
||||||
|
resource for resource in self.resources if resource.resource_name == resource_name
|
||||||
|
][0]
|
||||||
|
|
||||||
|
def get_resource_url(self, resource_name: str) -> str:
|
||||||
|
return self.url + self.get_resource(resource_name).url
|
||||||
|
|
||||||
|
def get_resource_provider(self, resource_provider_id: str) -> ResourceProvider:
|
||||||
|
return [
|
||||||
|
provider
|
||||||
|
for provider in self.resource_providers
|
||||||
|
if provider.id == resource_provider_id
|
||||||
|
][0]
|
||||||
|
|
|
@ -5,10 +5,8 @@ import logging
|
||||||
from fastapi import HTTPException, Request, Depends, status
|
from fastapi import HTTPException, Request, Depends, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
|
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
|
||||||
from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
|
|
||||||
|
|
||||||
# from authlib.oauth1.auth import OAuthToken
|
|
||||||
from authlib.oauth2.rfc6749 import OAuth2Token
|
from authlib.oauth2.rfc6749 import OAuth2Token
|
||||||
|
from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
|
||||||
|
|
||||||
from oidc_test.auth.provider import Provider
|
from oidc_test.auth.provider import Provider
|
||||||
from oidc_test.models import User
|
from oidc_test.models import User
|
||||||
|
@ -22,7 +20,7 @@ logger = logging.getLogger("oidc-test")
|
||||||
async def fetch_token(name, request):
|
async def fetch_token(name, request):
|
||||||
assert name is not None
|
assert name is not None
|
||||||
assert request is not None
|
assert request is not None
|
||||||
logger.warn("TODO: fetch_token")
|
logger.warning("TODO: fetch_token")
|
||||||
...
|
...
|
||||||
# if name in oidc_providers:
|
# if name in oidc_providers:
|
||||||
# model = OAuth2Token
|
# model = OAuth2Token
|
||||||
|
@ -34,7 +32,10 @@ async def fetch_token(name, request):
|
||||||
|
|
||||||
|
|
||||||
async def update_token(
|
async def update_token(
|
||||||
provider_id, token, refresh_token: str | None = None, access_token: str | None = None
|
provider_id,
|
||||||
|
token,
|
||||||
|
refresh_token: str | None = None,
|
||||||
|
access_token: str | None = None,
|
||||||
):
|
):
|
||||||
"""Update the token in the database"""
|
"""Update the token in the database"""
|
||||||
provider = providers[provider_id]
|
provider = providers[provider_id]
|
||||||
|
|
|
@ -6,6 +6,9 @@ from typing import Annotated
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
import logging.config
|
||||||
|
import importlib.resources
|
||||||
|
from yaml import safe_load
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@ -26,6 +29,7 @@ from authlib.oauth2.rfc6749 import OAuth2Token
|
||||||
# from fastapi.security import OpenIdConnect
|
# from fastapi.security import OpenIdConnect
|
||||||
# from pkce import generate_code_verifier, generate_pkce_pair
|
# from pkce import generate_code_verifier, generate_pkce_pair
|
||||||
|
|
||||||
|
from oidc_test import __version__
|
||||||
from oidc_test.registry import registry
|
from oidc_test.registry import registry
|
||||||
from oidc_test.auth.provider import NoPublicKey, Provider
|
from oidc_test.auth.provider import NoPublicKey, Provider
|
||||||
from oidc_test.auth.utils import (
|
from oidc_test.auth.utils import (
|
||||||
|
@ -46,6 +50,15 @@ from oidc_test.resource_server import resource_server
|
||||||
|
|
||||||
logger = logging.getLogger("oidc-test")
|
logger = logging.getLogger("oidc-test")
|
||||||
|
|
||||||
|
if settings.log:
|
||||||
|
assert __package__ is not None
|
||||||
|
with (
|
||||||
|
importlib.resources.path(__package__) as package_path,
|
||||||
|
open(package_path / settings.log_config_file) as f,
|
||||||
|
):
|
||||||
|
logging_config = safe_load(f)
|
||||||
|
logging.config.dictConfig(logging_config)
|
||||||
|
|
||||||
templates = Jinja2Templates(Path(__file__).parent / "templates")
|
templates = Jinja2Templates(Path(__file__).parent / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
@ -96,7 +109,7 @@ async def home(
|
||||||
"show_token": settings.show_token,
|
"show_token": settings.show_token,
|
||||||
"user": user,
|
"user": user,
|
||||||
"now": datetime.now(),
|
"now": datetime.now(),
|
||||||
"auth_provider": provider,
|
"__version__": __version__,
|
||||||
}
|
}
|
||||||
if provider is None or token is None:
|
if provider is None or token is None:
|
||||||
context["providers"] = providers
|
context["providers"] = providers
|
||||||
|
@ -105,26 +118,29 @@ async def home(
|
||||||
context["access_token_parsed"] = None
|
context["access_token_parsed"] = None
|
||||||
context["refresh_token_parsed"] = None
|
context["refresh_token_parsed"] = None
|
||||||
context["resources"] = None
|
context["resources"] = None
|
||||||
|
context["auth_provider"] = None
|
||||||
else:
|
else:
|
||||||
|
context["auth_provider"] = provider
|
||||||
context["access_token"] = token["access_token"]
|
context["access_token"] = token["access_token"]
|
||||||
# XXX: resources defined externally? I am confused...
|
|
||||||
try:
|
try:
|
||||||
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
|
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
|
||||||
|
context["access_token_parsed"] = access_token_parsed
|
||||||
|
context["access_token_scope"] = access_token_parsed.get("scope")
|
||||||
except PyJWTError as err:
|
except PyJWTError as err:
|
||||||
access_token_parsed = {"Cannot parse": err.__class__.__name__}
|
context["access_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
||||||
try:
|
|
||||||
context["access_token_scope"] = access_token_parsed["scope"]
|
|
||||||
except KeyError:
|
|
||||||
context["access_token_scope"] = None
|
context["access_token_scope"] = None
|
||||||
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
|
|
||||||
context["access_token_parsed"] = access_token_parsed
|
|
||||||
context["resource_providers"] = registry.resource_providers
|
|
||||||
try:
|
try:
|
||||||
context["refresh_token_parsed"] = provider.decode(
|
id_token_parsed = provider.decode(token["id_token"], verify_signature=False)
|
||||||
token["refresh_token"], verify_signature=False
|
context["id_token_parsed"] = id_token_parsed
|
||||||
)
|
except PyJWTError as err:
|
||||||
|
context["id_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
||||||
|
try:
|
||||||
|
refresh_token_parsed = provider.decode(token["refresh_token"], verify_signature=False)
|
||||||
|
context["refresh_token_parsed"] = refresh_token_parsed
|
||||||
except PyJWTError as err:
|
except PyJWTError as err:
|
||||||
context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
||||||
|
context["resources"] = registry.resources
|
||||||
|
context["resource_providers"] = provider.resource_providers
|
||||||
return templates.TemplateResponse(name="home.html", request=request, context=context)
|
return templates.TemplateResponse(name="home.html", request=request, context=context)
|
||||||
|
|
||||||
|
|
||||||
|
@ -246,7 +262,7 @@ async def logout(
|
||||||
if (
|
if (
|
||||||
provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
|
provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
|
||||||
) is None:
|
) is None:
|
||||||
logger.warn(f"Cannot find end_session_endpoint for provider {provider.id}")
|
logger.warning(f"Cannot find end_session_endpoint for provider {provider.id}")
|
||||||
return RedirectResponse(request.url_for("non_compliant_logout"))
|
return RedirectResponse(request.url_for("non_compliant_logout"))
|
||||||
post_logout_uri = request.url_for("home")
|
post_logout_uri = request.url_for("home")
|
||||||
# Clear session
|
# Clear session
|
||||||
|
@ -255,19 +271,15 @@ async def logout(
|
||||||
try:
|
try:
|
||||||
token = await db.get_token(provider, request.session.pop("sid", None))
|
token = await db.get_token(provider, request.session.pop("sid", None))
|
||||||
except TokenNotInDb:
|
except TokenNotInDb:
|
||||||
logger.warn("No session in db for the token or no token")
|
logger.warning("No session in db for the token or no token")
|
||||||
return RedirectResponse(request.url_for("home"))
|
return RedirectResponse(request.url_for("home"))
|
||||||
logout_url = (
|
url_query = {
|
||||||
provider_logout_uri
|
"post_logout_redirect_uri": post_logout_uri,
|
||||||
+ "?"
|
"client_id": provider.client_id,
|
||||||
+ urlencode(
|
}
|
||||||
{
|
if provider.logout_with_id_token_hint:
|
||||||
"post_logout_redirect_uri": post_logout_uri,
|
url_query["id_token_hint"] = token["id_token"]
|
||||||
"id_token_hint": token["id_token"],
|
logout_url = f"{provider_logout_uri}?{urlencode(url_query)}"
|
||||||
"client_id": "oidc_local_test",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return RedirectResponse(logout_url)
|
return RedirectResponse(logout_url)
|
||||||
|
|
||||||
|
|
||||||
|
@ -298,7 +310,13 @@ async def refresh(
|
||||||
refresh_token=token["refresh_token"],
|
refresh_token=token["refresh_token"],
|
||||||
grant_type="refresh_token",
|
grant_type="refresh_token",
|
||||||
)
|
)
|
||||||
await update_token(provider.id, new_token)
|
try:
|
||||||
|
await update_token(provider.id, new_token)
|
||||||
|
except PyJWTError as err:
|
||||||
|
logger.info(f"Cannot refresh token: {err.__class__.__name__}")
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_510_NOT_EXTENDED, f"Token refresh error: {err.__class__.__name__}"
|
||||||
|
)
|
||||||
return RedirectResponse(url=request.url_for("home"))
|
return RedirectResponse(url=request.url_for("home"))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ class ProcessError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ResourceProvider(BaseModel):
|
class Resource(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
scope_required: str | None = None
|
scope_required: str | None = None
|
||||||
role_required: str | None = None
|
role_required: str | None = None
|
||||||
|
@ -35,13 +35,13 @@ class ResourceProvider(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ResourceRegistry(BaseModel):
|
class ResourceRegistry(BaseModel):
|
||||||
resource_providers: dict[str, ResourceProvider] = {}
|
resources: dict[str, Resource] = {}
|
||||||
|
|
||||||
def make_registry(self):
|
def make_registry(self):
|
||||||
for ep in entry_points().select(group="oidc_test.resource_provider"):
|
for ep in entry_points().select(group="oidc_test.resource_provider"):
|
||||||
ResourceProviderClass = ep.load()
|
ResourceClass = ep.load()
|
||||||
if issubclass(ResourceProviderClass, ResourceProvider):
|
if issubclass(ResourceClass, Resource):
|
||||||
self.resource_providers[ep.name] = ResourceProviderClass(ep.name)
|
self.resources[ep.name] = ResourceClass(ep.name)
|
||||||
|
|
||||||
|
|
||||||
registry = ResourceRegistry()
|
registry = ResourceRegistry()
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
import logging
|
import logging
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
from authlib.oauth2.rfc6749 import OAuth2Token
|
from authlib.oauth2.rfc6749 import OAuth2Token
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient, HTTPError
|
||||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
from jwt.exceptions import DecodeError, ExpiredSignatureError, InvalidTokenError
|
||||||
from fastapi import FastAPI, HTTPException, Depends, Request, status
|
from fastapi import FastAPI, HTTPException, Depends, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
@ -17,7 +18,7 @@ from oidc_test.auth.utils import (
|
||||||
oauth2_scheme_optional,
|
oauth2_scheme_optional,
|
||||||
)
|
)
|
||||||
from oidc_test.auth_providers import providers
|
from oidc_test.auth_providers import providers
|
||||||
from oidc_test.settings import settings
|
from oidc_test.settings import ResourceProvider, settings
|
||||||
from oidc_test.models import User
|
from oidc_test.models import User
|
||||||
from oidc_test.registry import ProcessError, ProcessResult, registry
|
from oidc_test.registry import ProcessError, ProcessResult, registry
|
||||||
|
|
||||||
|
@ -48,7 +49,7 @@ resource_server.add_middleware(
|
||||||
|
|
||||||
@resource_server.get("/")
|
@resource_server.get("/")
|
||||||
async def resources() -> dict[str, dict[str, Any]]:
|
async def resources() -> dict[str, dict[str, Any]]:
|
||||||
return {"internal": {}, "plugins": registry.resource_providers}
|
return {"internal": {}, "plugins": registry.resources}
|
||||||
|
|
||||||
|
|
||||||
@resource_server.get("/{resource_name}")
|
@resource_server.get("/{resource_name}")
|
||||||
|
@ -65,8 +66,46 @@ async def get_resource(
|
||||||
# Get the resource if it's defined in user auth provider's resources (external)
|
# Get the resource if it's defined in user auth provider's resources (external)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
provider = providers[user.auth_provider_id]
|
provider = providers[user.auth_provider_id]
|
||||||
|
if ":" in resource_name:
|
||||||
|
# Third-party resource provider: send the request with the request token
|
||||||
|
resource_provider_id, resource_name = resource_name.split(":", 1)
|
||||||
|
provider = providers[user.auth_provider_id]
|
||||||
|
resource_provider: ResourceProvider = provider.get_resource_provider(
|
||||||
|
resource_provider_id
|
||||||
|
)
|
||||||
|
resource_url = resource_provider.get_resource_url(resource_name)
|
||||||
|
async with AsyncClient(verify=resource_provider.verify_ssl) as client:
|
||||||
|
try:
|
||||||
|
logger.debug(f"GET request to {resource_url}")
|
||||||
|
resp = await client.get(
|
||||||
|
resource_url,
|
||||||
|
headers={
|
||||||
|
"Content-type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"auth_provider": user.auth_provider_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except HTTPError as err:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE, err.__class__.__name__
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, err.__class__.__name__
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if resp.is_success:
|
||||||
|
return resp.json()
|
||||||
|
else:
|
||||||
|
reason_str: str
|
||||||
|
try:
|
||||||
|
reason_str = resp.json().get("detail", str(resp))
|
||||||
|
except Exception:
|
||||||
|
reason_str = str(resp.text)
|
||||||
|
raise HTTPException(resp.status_code, reason_str)
|
||||||
# Third party resource (provided through the auth provider)
|
# Third party resource (provided through the auth provider)
|
||||||
# The token is just passed on
|
# The token is just passed on
|
||||||
|
# XXX: is this branch valid anymore?
|
||||||
if resource_name in [r.resource_name for r in provider.resources]:
|
if resource_name in [r.resource_name for r in provider.resources]:
|
||||||
return await get_auth_provider_resource(
|
return await get_auth_provider_resource(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
@ -75,31 +114,31 @@ async def get_resource(
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
# Internal resource (provided here)
|
# Internal resource (provided here)
|
||||||
if resource_name in registry.resource_providers:
|
if resource_name in registry.resources:
|
||||||
resource_provider = registry.resource_providers[resource_name]
|
resource = registry.resources[resource_name]
|
||||||
reason: dict[str, str] = {}
|
reason: dict[str, str] = {}
|
||||||
if not resource_provider.is_public:
|
if not resource.is_public:
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public")
|
||||||
else:
|
else:
|
||||||
if resource_provider.scope_required is not None and not user.has_scope(
|
if resource.scope_required is not None and not user.has_scope(
|
||||||
resource_provider.scope_required
|
resource.scope_required
|
||||||
):
|
):
|
||||||
reason["scope"] = (
|
reason["scope"] = (
|
||||||
f"No scope {resource_provider.scope_required} in the access token "
|
f"No scope {resource.scope_required} in the access token "
|
||||||
"but it is required for accessing this resource"
|
"but it is required for accessing this resource"
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
resource_provider.role_required is not None
|
resource.role_required is not None
|
||||||
and resource_provider.role_required not in user.roles_as_set
|
and resource.role_required not in user.roles_as_set
|
||||||
):
|
):
|
||||||
reason["role"] = (
|
reason["role"] = (
|
||||||
f"You don't have the role {resource_provider.role_required} "
|
f"You don't have the role {resource.role_required} "
|
||||||
"but it is required for accessing this resource"
|
"but it is required for accessing this resource"
|
||||||
)
|
)
|
||||||
if len(reason) == 0:
|
if len(reason) == 0:
|
||||||
try:
|
try:
|
||||||
resp = await resource_provider.process(user=user, resource_id=resource_id)
|
resp = await resource.process(user=user, resource_id=resource_id)
|
||||||
return resp
|
return resp
|
||||||
except ProcessError as err:
|
except ProcessError as err:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
@ -116,12 +155,11 @@ async def get_auth_provider_resource(
|
||||||
provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
|
provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
|
||||||
) -> ProcessResult:
|
) -> ProcessResult:
|
||||||
if token is None:
|
if token is None:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token")
|
||||||
access_token = token["access_token"]
|
access_token = token
|
||||||
resource = [r for r in provider.resources if r.resource_name == resource_name][0]
|
|
||||||
async with AsyncClient() as client:
|
async with AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
url=provider.url + resource.url,
|
url=provider.get_resource_url(resource_name),
|
||||||
headers={
|
headers={
|
||||||
"Content-type": "application/json",
|
"Content-type": "application/json",
|
||||||
"Authorization": f"Bearer {access_token}",
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
@ -132,9 +170,19 @@ async def get_auth_provider_resource(
|
||||||
# Only a demo, real application would really process the response
|
# Only a demo, real application would really process the response
|
||||||
resp_length = len(resp.text)
|
resp_length = len(resp.text)
|
||||||
if resp_length > 1024:
|
if resp_length > 1024:
|
||||||
return ProcessResult(msg=f"The resource is too long ({resp_length} bytes) to show here")
|
return ProcessResult(
|
||||||
|
msg=f"The resource is too long ({resp_length} bytes) to show in this demo, here is just the begining in raw format",
|
||||||
|
start=resp.text[:100] + "...",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return ProcessResult(**resp.json())
|
try:
|
||||||
|
resp_json = resp.json()
|
||||||
|
except JSONDecodeError:
|
||||||
|
return ProcessResult(msg="The resource is not formatted in JSON", text=resp.text)
|
||||||
|
if isinstance(resp_json, dict):
|
||||||
|
return ProcessResult(**resp.json())
|
||||||
|
elif isinstance(resp_json, list):
|
||||||
|
return ProcessResult(**{str(i): line for i, line in enumerate(resp_json)})
|
||||||
|
|
||||||
|
|
||||||
# @resource_server.get("/public")
|
# @resource_server.get("/public")
|
||||||
|
|
|
@ -4,7 +4,7 @@ import random
|
||||||
from typing import Type, Tuple
|
from typing import Type, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, computed_field, AnyUrl
|
from pydantic import AnyHttpUrl, BaseModel, computed_field, AnyUrl
|
||||||
from pydantic_settings import (
|
from pydantic_settings import (
|
||||||
BaseSettings,
|
BaseSettings,
|
||||||
SettingsConfigDict,
|
SettingsConfigDict,
|
||||||
|
@ -22,6 +22,22 @@ class Resource(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceProvider(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
base_url: AnyUrl
|
||||||
|
resources: list[Resource] = []
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
def get_resource(self, resource_name: str) -> Resource:
|
||||||
|
return [
|
||||||
|
resource for resource in self.resources if resource.resource_name == resource_name
|
||||||
|
][0]
|
||||||
|
|
||||||
|
def get_resource_url(self, resource_name: str) -> str:
|
||||||
|
return f"{self.base_url}{self.get_resource(resource_name).url}"
|
||||||
|
|
||||||
|
|
||||||
class AuthProviderSettings(BaseModel):
|
class AuthProviderSettings(BaseModel):
|
||||||
"""Auth provider, can also be a resource server"""
|
"""Auth provider, can also be a resource server"""
|
||||||
|
|
||||||
|
@ -45,6 +61,7 @@ class AuthProviderSettings(BaseModel):
|
||||||
session_key: str = "sid"
|
session_key: str = "sid"
|
||||||
skip_verify_signature: bool = True
|
skip_verify_signature: bool = True
|
||||||
disabled: bool = False
|
disabled: bool = False
|
||||||
|
resource_providers: list[ResourceProvider] = []
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
|
@ -67,13 +84,6 @@ class AuthProviderSettings(BaseModel):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ResourceProvider(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
base_url: AnyUrl
|
|
||||||
resources: list[Resource] = []
|
|
||||||
|
|
||||||
|
|
||||||
class AuthSettings(BaseModel):
|
class AuthSettings(BaseModel):
|
||||||
show_session_details: bool = False
|
show_session_details: bool = False
|
||||||
providers: list[AuthProviderSettings] = []
|
providers: list[AuthProviderSettings] = []
|
||||||
|
@ -92,13 +102,14 @@ class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
||||||
|
|
||||||
auth: AuthSettings = AuthSettings()
|
auth: AuthSettings = AuthSettings()
|
||||||
resource_providers: list[ResourceProvider] = []
|
|
||||||
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
|
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
|
||||||
log: bool = False
|
log: bool = False
|
||||||
|
log_config_file: str = "log_conf.yaml"
|
||||||
insecure: Insecure = Insecure()
|
insecure: Insecure = Insecure()
|
||||||
cors_origins: list[str] = []
|
cors_origins: list[str] = []
|
||||||
debug_token: bool = False
|
debug_token: bool = False
|
||||||
show_token: bool = False
|
show_token: bool = False
|
||||||
|
show_external_resource_providers_links: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def settings_customise_sources(
|
def settings_customise_sources(
|
||||||
|
|
|
@ -21,6 +21,12 @@ hr {
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.version {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 75%;
|
||||||
|
top: 0.3em;
|
||||||
|
right: 0.3em;
|
||||||
|
}
|
||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,9 @@ async function checkHref(elem, token, authProvider) {
|
||||||
const msg = document.getElementById("msg")
|
const msg = document.getElementById("msg")
|
||||||
const resourceName = elem.getAttribute("resource-name")
|
const resourceName = elem.getAttribute("resource-name")
|
||||||
const resourceId = elem.getAttribute("resource-id")
|
const resourceId = elem.getAttribute("resource-id")
|
||||||
const url = resourceId ? `resource/${resourceName}/${resourceId}` : `resource/${resourceName}`
|
const resourceProviderId = elem.getAttribute("resource-provider-id") ? elem.getAttribute("resource-provider-id") : ""
|
||||||
|
const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName
|
||||||
|
const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}`
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
|
@ -30,11 +32,13 @@ function checkPerms(className, token, authProvider) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get_resource(resource_name, token, authProvider, resource_id) {
|
async function get_resource(resourceName, token, authProvider, resourceId, resourceProviderId) {
|
||||||
|
// BaseUrl for an external resource provider
|
||||||
//if (!keycloak.keycloak) { return }
|
//if (!keycloak.keycloak) { return }
|
||||||
const msg = document.getElementById("msg")
|
const msg = document.getElementById("msg")
|
||||||
const resourceElem = document.getElementById('resource')
|
const resourceElem = document.getElementById('resource')
|
||||||
const url = resource_id ? `resource/${resource_name}/${resource_id}` : `resource/${resource_name}`
|
const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName
|
||||||
|
const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}`
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<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', '{{ access_token }}', '{{ auth_provider.id }}')">
|
<body onload="checkPerms('links-to-check', '{{ access_token }}', '{{ auth_provider.id }}')">
|
||||||
|
<div class="version">v. {{ __version__}}</div>
|
||||||
<h1>OIDC-test - FastAPI client</h1>
|
<h1>OIDC-test - FastAPI client</h1>
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -66,29 +66,67 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% if resource_providers %}
|
{% if resources %}
|
||||||
<p>
|
<p>This application provides all these resources, eventually protected with scope or roles:</p>
|
||||||
{{ auth_provider.name }} provides these resources:
|
|
||||||
</p>
|
|
||||||
<div class="links-to-check">
|
<div class="links-to-check">
|
||||||
{% for name, resource_provider in resource_providers.items() %}
|
{% for name, resource in resources.items() %}
|
||||||
{% if resource_provider.default_resource_id %}
|
{% if resource.default_resource_id %}
|
||||||
<button resource-name="{{ name }}"
|
<button resource-name="{{ name }}"
|
||||||
resource-id="{{ resource_provider.default_resource_id }}"
|
resource-id="{{ resource.default_resource_id }}"
|
||||||
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}', '{{ resource_provider.default_resource_id }}')"
|
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}', '{{ resource.default_resource_id }}')"
|
||||||
>
|
>
|
||||||
{{ resource_provider.name }}
|
{{ resource.name }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button resource-name="{{ name }}"
|
<button resource-name="{{ name }}"
|
||||||
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}')"
|
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}')"
|
||||||
>
|
>
|
||||||
{{ resource_provider.name }}
|
{{ resource.name }}
|
||||||
</buttona>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if auth_provider.resources %}
|
||||||
|
<p>{{ auth_provider.name }} is also defined as a provider for these resources:</p>
|
||||||
|
<div class="links-to-check">
|
||||||
|
{% for resource in auth_provider.resources %}
|
||||||
|
{% if resource.default_resource_id %}
|
||||||
|
<button resource-name="{{ resource.resource_name }}"
|
||||||
|
resource-id="{{ resource.default_resource_id }}"
|
||||||
|
onclick="get_resource('{{ resource.resource_name }}', '{{ access_token }}', '{{ auth_provider.id }}', '{{ resource.default_resource_id }}')"
|
||||||
|
>
|
||||||
|
{{ resource.name }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button resource-name="{{ resource.resource_name }}"
|
||||||
|
onclick="get_resource('{{ resource.resource_name }}', '{{ access_token }}', '{{ auth_provider.id }}')"
|
||||||
|
>
|
||||||
|
{{ resource.name }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if resource_providers %}
|
||||||
|
<p>{{ auth_provider.name }} allows this application to request resources from third party resource providers:</p>
|
||||||
|
{% for resource_provider in resource_providers %}
|
||||||
|
<div class="links-to-check">
|
||||||
|
{{ resource_provider.name }}
|
||||||
|
{% for resource in resource_provider.resources %}
|
||||||
|
<button resource-name="{{ resource.resource_name }}"
|
||||||
|
resource-id="{{ resource.default_resource_id }}"
|
||||||
|
resource-provider-id="{{ resource_provider.id }}"
|
||||||
|
onclick="get_resource('{{ resource.resource_name }}', '{{ access_token }}',
|
||||||
|
'{{ auth_provider.id }}', '{{ resource.default_resource_id }}',
|
||||||
|
'{{ resource_provider.id }}')"
|
||||||
|
>
|
||||||
|
{{ resource.name }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
<div class="resourceResult">
|
<div class="resourceResult">
|
||||||
<div id="resource" class="resource"></div>
|
<div id="resource" class="resource"></div>
|
||||||
<div id="msg" class="msg error"></div>
|
<div id="msg" class="msg error"></div>
|
||||||
|
|
16
uv.lock
generated
16
uv.lock
generated
|
@ -1,4 +1,5 @@
|
||||||
version = 1
|
version = 1
|
||||||
|
revision = 1
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -206,6 +207,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
|
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunamai"
|
||||||
|
version = "1.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/4e/a5c8c337a1d9ac0384298ade02d322741fb5998041a5ea74d1cd2a4a1d47/dunamai-1.23.0.tar.gz", hash = "sha256:a163746de7ea5acb6dacdab3a6ad621ebc612ed1e528aaa8beedb8887fccd2c4", size = 44681 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/4c/963169386309fec4f96fd61210ac0a0666887d0fb0a50205395674d20b71/dunamai-1.23.0-py3-none-any.whl", hash = "sha256:a0906d876e92441793c6a423e16a4802752e723e9c9a5aabdc5535df02dbe041", size = 26342 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.19.0"
|
version = "0.19.0"
|
||||||
|
@ -482,7 +495,6 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oidc-fastapi-test"
|
name = "oidc-fastapi-test"
|
||||||
version = "0.0.0"
|
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
|
@ -501,6 +513,7 @@ dependencies = [
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "dunamai" },
|
||||||
{ name = "ipdb" },
|
{ name = "ipdb" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
@ -523,6 +536,7 @@ requires-dist = [
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "dunamai", specifier = ">=1.23.0" },
|
||||||
{ name = "ipdb", specifier = ">=0.13.13" },
|
{ name = "ipdb", specifier = ">=0.13.13" },
|
||||||
{ name = "pytest", specifier = ">=8.3.4" },
|
{ name = "pytest", specifier = ">=8.3.4" },
|
||||||
]
|
]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue