Compare commits

..

No commits in common. "main" and "0.1.60" have entirely different histories.
main ... 0.1.60

18 changed files with 174 additions and 270 deletions

View file

@ -0,0 +1,93 @@
on:
push:
workflow_dispatch:
inputs:
verbose:
description: "Verbose"
required: false
default: false
type: boolean
jobs:
build:
runs-on: container
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v4
with:
version: "0.5.16"
- name: Install
run: uv sync
- name: Run tests (API call)
run: .venv/bin/pytest -s tests/basic.py
- name: Get version with git describe
id: version
run: |
echo "version=$(git describe)" >> $GITHUB_OUTPUT
echo "$VERSION"
- name: Check if the container should be built
id: builder
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)
if: fromJSON(steps.builder.outputs.run)
env:
VERSION: ${{ steps.version.outputs.version }}
run: sed "s/0.0.0/$VERSION/" -i pyproject.toml
- name: Workaround for bug of podman-login
if: fromJSON(steps.builder.outputs.run)
run: |
mkdir -p $HOME/.docker
echo "{ \"auths\": {} }" > $HOME/.docker/config.json
- name: Log in to the container registry (with another workaround)
if: fromJSON(steps.builder.outputs.run)
uses: actions/podman-login@v1
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
auth_file_path: /tmp/auth.json
- name: Build the container image
if: fromJSON(steps.builder.outputs.run)
uses: actions/buildah-build@v1
with:
image: oidc-fastapi-test
oci: true
labels: oidc-fastapi-test
tags: latest ${{ steps.version.outputs.version }}
containerfiles: |
./Containerfile
- name: Push the image to the registry
if: fromJSON(steps.builder.outputs.run)
uses: actions/push-to-registry@v2
with:
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
image: oidc-fastapi-test
tags: latest ${{ steps.version.outputs.version }}
- name: Build wheel
if: fromJSON(steps.builder.outputs.run)
run: uv build --wheel
- name: Publish Python package (home)
if: fromJSON(steps.builder.outputs.run)
env:
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
continue-on-error: true

View file

@ -0,0 +1,28 @@
on:
push:
workflow_dispatch:
inputs:
verbose:
description: "Verbose"
required: false
default: false
type: boolean
jobs:
test:
runs-on: container
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v4
with:
version: "0.5.16"
- name: Install
run: uv sync
- name: Run tests (API call)
run: .venv/bin/pytest -s tests/basic.py

View file

@ -1,63 +0,0 @@
when:
- event: manual
- event: tag
depends_on:
- test
steps:
python_sync:
image: code.philo.ydns.eu/philorg/uv
volumes:
- uv-cache:/uv-cache
environment:
UV_CACHE_DIR: /uv-cache
UV_LINK_MODE: copy
commands:
- uv sync
python_build:
image: code.philo.ydns.eu/philorg/uv
volumes:
- uv-cache:/uv-cache
environment:
UV_CACHE_DIR: /uv-cache
UV_LINK_MODE: copy
commands:
- uv build --wheel
- uv cache prune --ci
python_publish:
image: code.philo.ydns.eu/philorg/uv
environment:
OWNER: philorg
REGISTRY_URL: https://code.philo.ydns.eu
REGISTRY_TOKEN:
from_secret: registry_token
commands:
- uv publish --publish-url $REGISTRY_URL/api/packages/$OWNER/pypi --token $REGISTRY_TOKEN dist/*.whl
failure: ignore
container_build_publish:
image: quay.io/podman/stable:latest
# Caution: This image is built daily. It might fill up your image store quickly.
#pull: true
volumes:
- containers:/var/lib/containers
- uv:/root/.cache/uv
# Fill in the trusted checkbox in Woodpecker's settings as well
privileged: true
environment:
registry: code.philo.ydns.eu
org: philorg
container_name: oidc-fastapi-test
registry_token:
from_secret: registry_token
commands:
# Login at the registry
- podman login -u __token__ --password $registry_token $registry
# Build the container image
- podman build --volume=/var/lib/containers:/var/lib/containers --tag $registry/$org/$container_name:latest --tag $registry/$org/$container_name:$CI_COMMIT_TAG .
# Push the image
- podman push $registry/$org/$container_name:latest
- podman push $registry/$org/$container_name:$CI_COMMIT_TAG

View file

@ -1,21 +0,0 @@
when:
- event: push
branch: main
- event: manual
- event: tag
steps:
sync:
image: code.philo.ydns.eu/philorg/uv
volumes:
- uv-cache:/uv-cache
environment:
UV_CACHE_DIR: /uv-cache
UV_LINK_MODE: copy
commands:
- uv sync
test:
image: code.philo.ydns.eu/philorg/uv
commands:
- .venv/bin/pytest -s tests/basic.py

View file

@ -1,7 +1,6 @@
FROM docker.io/library/python:3.13-slim
FROM docker.io/library/python:alpine
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
COPY --from=docker.io/python:3.13 /usr/bin/git /usr/local/bin/git
COPY . /app

View file

@ -52,59 +52,31 @@ given by the OIDC providers.
For example:
```yaml
secret_key: AVeryWellKeptSecret
debug_token: no
show_token: yes
log: yes
auth:
oidc:
secret_key: "ASecretNoOneKnows"
show_session_details: yes
providers:
- id: auth0
name: Okta / Auth0
url: https://<your_auth0_app_URL>
public_key_url: https://<your_auth0_app_URL>/pem
client_id: <your_auth0_client_id>
client_secret: client_secret_generated_by_auth0
hint: A hint for test credentials
url: "https://<your_auth0_app_URL>"
client_id: "<your_auth0_client_id>"
client_secret: "client_secret_generated_by_auth0"
hint: "A hint for test credentials"
- id: keycloak
name: Keycloak at somewhere
url: https://<the_keycloak_realm_url>
info_url: https://philo.ydns.eu/auth/realms/test
account_url_template: /account
client_id: <your_keycloak_client_id>
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
url: "https://<the_keycloak_realm_url>"
account_url_template: "/account"
client_id: "<your_keycloak_client_id>"
client_secret: "client_secret_generated_by_keycloak"
hint: "User: foo, password: foofoo"
- id: codeberg
disabled: no
name: Codeberg
url: https://codeberg.org
account_url_template: /user/settings
client_id: <your_codeberg_client_id>
client_secret: client_secret_generated_by_codeberg
info_url: https://codeberg.org/login/oauth/keys
session_key: sub
skip_verify_signature: no
url: "https://codeberg.org"
account_url_template: "/user/settings"
client_id: "<your_codeberg_client_id>"
client_secret: "client_secret_generated_by_codeberg"
resources:
- name: List of repos
id: repos
@ -127,5 +99,3 @@ with the setting file in the current working directory:
```sh
podman run -p 8000:80 --env OIDC_TEST_CONFIG_FILE=/app/settings.yaml --mount type=bind,source=settings.yaml,destination=/app/settings.yaml code.philo.ydns.eu/philorg/oidc-fastapi-test:latest
```
[![status-badge](https://code.philo.ydns.eu/woodpecker/api/badges/22/status.svg)](https://code.philo.ydns.eu/woodpecker/repos/22)

View file

@ -1,7 +1,7 @@
[project]
name = "oidc-fastapi-test"
#version = "0.0.0"
dynamic = ["version"]
version = "0.0.0"
# dynamic = ["version"]
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
@ -24,21 +24,14 @@ dependencies = [
oidc-test = "oidc_test.main:main"
[dependency-groups]
dev = ["dunamai>=1.23.0", "ipdb>=0.13.13", "pytest>=8.3.4"]
dev = ["ipdb>=0.13.13", "pytest>=8.3.4"]
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.hatch.build.targets.wheel]
packages = ["src/oidc_test"]
package = true
[tool.uv-dynamic-versioning]
style = "semver"
[tool.uv]
package = true

View file

@ -1,11 +0,0 @@
import importlib.metadata
try:
from dunamai import Version, Style
__version__ = Version.from_git().serialize(style=Style.SemVer, dirty=True)
except (ImportError, RuntimeError):
# __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")

View file

@ -61,34 +61,28 @@ class Provider(AuthProviderSettings):
if self.info_url is not None:
try:
provider_info = await client.get(self.info_url)
except Exception as err:
logger.debug("Provider_info: cannot connect")
logger.exception(err)
except Exception:
raise NoPublicKey
try:
self.info = provider_info.json()
except JSONDecodeError:
logger.debug("Provider_info: cannot decode json response")
raise NoPublicKey
if "public_key" in self.info:
# For Keycloak
try:
public_key = str(self.info["public_key"])
except KeyError:
logger.debug("Provider_info: cannot get public_key")
raise NoPublicKey
elif "keys" in self.info:
# For Forgejo/Gitea
try:
public_key = str(self.info["keys"][0]["n"])
except KeyError:
logger.debug("Provider_info: cannot get key 0.n")
raise NoPublicKey
if self.public_key_url is not None:
resp = await client.get(self.public_key_url)
public_key = resp.text
if public_key is None:
logger.debug("Provider_info: cannot determine public key")
raise NoPublicKey
self.public_key = "\n".join(
["-----BEGIN PUBLIC KEY-----", public_key, "-----END PUBLIC KEY-----"]

View file

@ -5,9 +5,11 @@ import logging
from fastapi import HTTPException, Request, Depends, status
from fastapi.security import OAuth2PasswordBearer
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
from authlib.oauth2.rfc6749 import OAuth2Token
from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
# from authlib.oauth1.auth import OAuthToken
from authlib.oauth2.rfc6749 import OAuth2Token
from oidc_test.auth.provider import Provider
from oidc_test.models import User
from oidc_test.database import db, TokenNotInDb, UserNotInDB
@ -20,7 +22,7 @@ logger = logging.getLogger("oidc-test")
async def fetch_token(name, request):
assert name is not None
assert request is not None
logger.warning("TODO: fetch_token")
logger.warn("TODO: fetch_token")
...
# if name in oidc_providers:
# model = OAuth2Token
@ -32,10 +34,7 @@ async def fetch_token(name, request):
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"""
provider = providers[provider_id]

View file

@ -6,9 +6,6 @@ from typing import Annotated
from pathlib import Path
from datetime import datetime
import logging
import logging.config
import importlib.resources
from yaml import safe_load
from urllib.parse import urlencode
from contextlib import asynccontextmanager
@ -29,7 +26,6 @@ from authlib.oauth2.rfc6749 import OAuth2Token
# from fastapi.security import OpenIdConnect
# from pkce import generate_code_verifier, generate_pkce_pair
from oidc_test import __version__
from oidc_test.registry import registry
from oidc_test.auth.provider import NoPublicKey, Provider
from oidc_test.auth.utils import (
@ -50,15 +46,6 @@ from oidc_test.resource_server import resource_server
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")
@ -109,7 +96,7 @@ async def home(
"show_token": settings.show_token,
"user": user,
"now": datetime.now(),
"__version__": __version__,
"auth_provider": provider,
}
if provider is None or token is None:
context["providers"] = providers
@ -118,29 +105,26 @@ async def home(
context["access_token_parsed"] = None
context["refresh_token_parsed"] = None
context["resources"] = None
context["auth_provider"] = None
else:
context["auth_provider"] = provider
context["access_token"] = token["access_token"]
try:
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:
context["access_token_parsed"] = {"Cannot parse": err.__class__.__name__}
access_token_parsed = {"Cannot parse": err.__class__.__name__}
try:
context["access_token_scope"] = access_token_parsed["scope"]
except KeyError:
context["access_token_scope"] = None
try:
id_token_parsed = provider.decode(token["id_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:
context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
context["access_token_parsed"] = access_token_parsed
context["resources"] = registry.resources
context["resource_providers"] = provider.resource_providers
try:
context["refresh_token_parsed"] = provider.decode(
token["refresh_token"], verify_signature=False
)
except PyJWTError as err:
context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
return templates.TemplateResponse(name="home.html", request=request, context=context)
@ -310,13 +294,7 @@ async def refresh(
refresh_token=token["refresh_token"],
grant_type="refresh_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"))

View file

@ -1,10 +1,9 @@
from typing import Annotated, Any
import logging
from json import JSONDecodeError
from authlib.oauth2.rfc6749 import OAuth2Token
from httpx import AsyncClient, HTTPError
from jwt.exceptions import DecodeError, ExpiredSignatureError, InvalidTokenError
from httpx import AsyncClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from fastapi import FastAPI, HTTPException, Depends, Request, status
from fastapi.middleware.cors import CORSMiddleware
@ -76,7 +75,6 @@ async def get_resource(
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={
@ -85,10 +83,6 @@ async def get_resource(
"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__
@ -156,7 +150,7 @@ async def get_auth_provider_resource(
) -> ProcessResult:
if token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token")
access_token = token
access_token = token["access_token"]
async with AsyncClient() as client:
resp = await client.get(
url=provider.get_resource_url(resource_name),
@ -170,19 +164,9 @@ async def get_auth_provider_resource(
# Only a demo, real application would really process the response
resp_length = len(resp.text)
if resp_length > 1024:
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] + "...",
)
return ProcessResult(msg=f"The resource is too long ({resp_length} bytes) to show here")
else:
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")

View file

@ -104,7 +104,6 @@ class Settings(BaseSettings):
auth: AuthSettings = AuthSettings()
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
log: bool = False
log_config_file: str = "log_conf.yaml"
insecure: Insecure = Insecure()
cors_origins: list[str] = []
debug_token: bool = False

View file

@ -21,12 +21,6 @@ hr {
.hidden {
display: none;
}
.version {
position: absolute;
font-size: 75%;
top: 0.3em;
right: 0.3em;
}
.center {
text-align: center;
}

View file

@ -5,7 +5,6 @@
<script src="{{ url_for('static', path='/utils.js') }}"></script>
</head>
<body onload="checkPerms('links-to-check', '{{ access_token }}', '{{ auth_provider.id }}')">
<div class="version">v. {{ __version__}}</div>
<h1>OIDC-test - FastAPI client</h1>
{% block content %}
{% endblock %}

View file

@ -66,8 +66,12 @@
{% endif %}
<hr>
<div class="content">
<!--
-->
{% if resources %}
<p>This application provides all these resources, eventually protected with scope or roles:</p>
<p>
{{ auth_provider.name }} provides these resources:
</p>
<div class="links-to-check">
{% for name, resource in resources.items() %}
{% if resource.default_resource_id %}
@ -87,29 +91,8 @@
{% endfor %}
</div>
{% 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>
<p>{{ auth_provider.name }} can request resources from third party resource providers:</p>
{% for resource_provider in resource_providers %}
<div class="links-to-check">
{{ resource_provider.name }}

16
uv.lock generated
View file

@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.13"
[[package]]
@ -207,18 +206,6 @@ wheels = [
{ 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]]
name = "ecdsa"
version = "0.19.0"
@ -495,6 +482,7 @@ wheels = [
[[package]]
name = "oidc-fastapi-test"
version = "0.0.0"
source = { editable = "." }
dependencies = [
{ name = "authlib" },
@ -513,7 +501,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "dunamai" },
{ name = "ipdb" },
{ name = "pytest" },
]
@ -536,7 +523,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "dunamai", specifier = ">=1.23.0" },
{ name = "ipdb", specifier = ">=0.13.13" },
{ name = "pytest", specifier = ">=8.3.4" },
]