Usage

This package provides a Keycloak OIDC device flow handler and an httpx auth class that injects bearer tokens automatically.

Quick start

Using the package with httpx is straightforward. Create a device flow handler and a token cache, then create an HttpxDeviceFlowAuth instance with them. Pass the auth instance to an httpx client:

from acmadauth.handlers import KeycloakOIDCDeviceFlowHandler
from acmadauth.tokencaches import MemoryTokenCache
from pprint import pprint
from acmadauth.auth import HttpxDeviceFlowAuth
import httpx

realm = "https://auth.acmad.cloud.edu.au/realms/ACMADCI"
client_id = "acmad-ci"

handler = KeycloakOIDCDeviceFlowHandler(realm, client_id)
cache = MemoryTokenCache()

auth = HttpxDeviceFlowAuth(handler, cache, open_browser=True)

client = httpx.Client(auth=auth)
resp = client.get("https://acmad-ci.acmad.cloud.edu.au/api/v1/user/current_user/")
print(f"Request Status Code: {resp.status_code}")
pprint(resp.json())

If you prefer to use requests, you can use the RequestsDeviceFlowAuth class instead:

from acmadauth.handlers import KeycloakOIDCDeviceFlowHandler
from acmadauth.tokencaches import MemoryTokenCache
from pprint import pprint
from acmadauth.auth import RequestsDeviceFlowAuth
import requests

realm = "https://auth.acmad.cloud.edu.au/realms/ACMADCI"
client_id = "acmad-ci"

handler = KeycloakOIDCDeviceFlowHandler(realm, client_id)
cache = MemoryTokenCache()

auth = RequestsDeviceFlowAuth(handler, cache, open_browser=True)

resp = requests.get("https://acmad-ci.acmad.cloud.edu.au/api/v1/user/current_user/", auth=auth)
print(f"Request Status Code: {resp.status_code}")
pprint(resp.json())

Usage with keyring token cache

from acmadauth.handlers import KeycloakOIDCDeviceFlowHandler
from acmadauth.tokencaches import KeyringTokenCache
from pprint import pprint
from acmadauth.auth import HttpxDeviceFlowAuth
import httpx

realm = "https://auth.acmad.cloud.edu.au/realms/ACMADCI"
client_id = "acmad-ci"

handler = KeycloakOIDCDeviceFlowHandler(realm, client_id)
cache = KeyringTokenCache("acmad:oidc:ci-instance")

auth = HttpxDeviceFlowAuth(handler, cache, open_browser=True)

client = httpx.Client(auth=auth)
resp = client.get("https://acmad-ci.acmad.cloud.edu.au/api/v1/user/current_user/")
print(f"Request Status Code: {resp.status_code}")
pprint(resp.json())

Usage in a GUI application

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "acmadauth>=1.3.1",
#     "pypng>=0.20220715.0",
#     "pyqrcode>=1.2.1",
# ]
#
# [[tool.uv.index]]
# url = "https://pypi.acmad.cloud.edu.au/"
# ///

import threading
import tkinter as tk
from tkinter import messagebox

import httpx
import pyqrcode

from acmadauth.auth import HttpxDeviceFlowAuth
from acmadauth.handlers import KeycloakOIDCDeviceFlowHandler
from acmadauth.tokencaches import MemoryTokenCache

REALM_URL = "https://auth.acmad.cloud.edu.au/realms/ACMADCI"
CLIENT_ID = "acmad-ci"
USER_ENDPOINT = "https://acmad-ci.acmad.cloud.edu.au/api/v1/user/current_user/"


def main() -> None:
    root = tk.Tk()
    root.title("ACMAD Login")
    root.resizable(False, False)

    status_var = tk.StringVar(value="Click Login to authenticate.")

    status_label = tk.Label(root, textvariable=status_var, padx=12, pady=8)
    status_label.pack()

    url_label = tk.Label(root, text="", wraplength=320, justify="center", padx=12)
    url_label.pack()

    qr_label = tk.Label(root, padx=12, pady=8)
    qr_label.pack()

    login_button = tk.Button(root, text="Login")
    login_button.pack(pady=10)

    def set_qr_from_url(url: str) -> None:
        status_var.set("Scan the QR code or complete login in the browser.")
        url_label.config(text=url)
        qr = pyqrcode.create(url)
        png_base64 = qr.png_as_base64_str(scale=5)
        image = tk.PhotoImage(data=png_base64)
        qr_label.image = image
        qr_label.config(image=image)

    def login_url_callback(url: str) -> None:
        root.after(0, set_qr_from_url, url)

    def set_idle_state() -> None:
        status_var.set("Click Login to authenticate.")
        url_label.config(text="")
        qr_label.config(image="")
        qr_label.image = None
        login_button.config(state=tk.NORMAL)

    def set_busy_state() -> None:
        status_var.set("Starting authentication...")
        login_button.config(state=tk.DISABLED)

    def show_success(first_name: str, last_name: str) -> None:
        messagebox.showinfo(
            "Authenticated",
            f"Authenticated as {first_name} {last_name}.",
        )
        set_idle_state()

    def show_error(message: str) -> None:
        messagebox.showerror("Authentication failed", message)
        set_idle_state()

    def auth_worker() -> None:
        try:
            handler = KeycloakOIDCDeviceFlowHandler(REALM_URL, CLIENT_ID)
            cache = MemoryTokenCache()
            auth = HttpxDeviceFlowAuth(
                handler,
                cache,
                open_browser=True,
                show_qr=False,
                show_url=False,
                login_url_callback=login_url_callback,
            )
            with httpx.Client(auth=auth) as client:
                resp = client.get(USER_ENDPOINT)
                resp.raise_for_status()
                data = resp.json()
            first_name = data.get("first_name", "")
            last_name = data.get("last_name", "")
            root.after(0, show_success, first_name, last_name)
        except Exception as exc:
            root.after(0, show_error, str(exc))

    def on_login() -> None:
        set_busy_state()
        thread = threading.Thread(target=auth_worker, daemon=True)
        thread.start()

    login_button.config(command=on_login)

    root.mainloop()


if __name__ == "__main__":
    main()

To run this exmaple, save it to a file named gui_example.py and run it with uv:

pip install uv
uv run gui_example.py

Device authorisation flow

When tokens are missing or expired, the device flow is started. The handler prints a verification URL and optionally a QR code. You can also open a browser tab automatically with open_browser=True.