Skip to main content

Playful login and challenge rituals, with optional Authlib/OIDC integration.

Project description

funthenticate

The world keeps getting more serious, and login is usually one of its most serious little chores. Funthenticate gives that moment a grin: a playful Authlib wrapper that lets your app ask users to draw a key, crack a conversion lock, gamble on a secret number, or click the world's most confident popup. Stop there for a purely fun gate, or continue into a real OAuth/OIDC redirect when the stakes actually call for it.

When you do need real authentication, the important part stays intentionally boring: Authlib and your identity provider do the identity work. Funthenticate adds optional rituals, sequenced challenge state, deterministic welcome badges, and provider helpers so login feels less like a gray door and more like a tiny moment of relief.

Do not use these challenges as identity proof by themselves. They are theatrical velvet ropes, onboarding rituals, or small morale machines. Pair them with Microsoft Entra ID, Google, Okta, or another OIDC provider whenever identity actually matters.

Features

  • Authlib wrapper for Flask-backed OAuth/OIDC flows
  • Google and Microsoft Entra provider helpers
  • Drawing, conversion, operator-guessing, popup, and chance-based login rituals
  • Fun-only missions that do not need a provider redirect
  • Prompt sequences so multiple rituals can be chained together

Install

uv add funthenticate

For provider-backed OAuth/OIDC flows, install the Authlib extra:

uv add "funthenticate[auth]"

For local development:

uv sync
uv run pytest tests -xvs
uv run ruff check .
uv run ruff format . --check

Basic Flask Flow

Funthenticate can be used as a fun-only gate or as a playful pre-step before Authlib.

flowchart TD
    A[Start fun mission] --> B[Show current prompt]
    B --> C{Prompt passed?}
    C -- no --> B
    C -- yes --> D{More prompts?}
    D -- yes --> E[Advance to next prompt]
    E --> B
    D -- no --> F{Provider auth configured?}
    F -- no --> G[complete_fun]
    F -- yes --> H[redirect_to_provider]
    H --> I[OAuth/OIDC provider]
    I --> J[complete_login]
    G --> K[App continues]
    J --> K

Fun-Only Flow

from flask import Flask, redirect, session

from funthenticate import FunAuth

app = Flask(__name__)
app.secret_key = "replace-me"

auth = FunAuth()


@app.get("/login")
def login():
    mission = auth.prepare_mission(
        session,
        next_url="/dashboard",
        prompt_keys=("authorized-popup", "number-guess"),
    )
    return mission.prompt.prompt


@app.post("/login/popup")
def answer_popup():
    result = auth.answer_popup(session, accepted=True)
    if not result.passed:
        return result.message, 400
    return auth.current_mission(session).prompt.prompt


@app.post("/login/guess")
def answer_guess():
    result = auth.answer_number_guess(session, 7)
    if not result.passed:
        return result.message, 400
    done = auth.complete_fun(session)
    return redirect(done.next_url or "/")

Provider Flow

from flask import Flask, redirect, request, session, url_for

from funthenticate import FunAuth, google_provider

app = Flask(__name__)
app.secret_key = "replace-me"

auth = FunAuth.for_flask_app(app, trusted_email_domains=["example.com"])
auth.register_provider(google_provider("client-id", "client-secret"))


@app.get("/login")
def login():
    mission = auth.prepare_mission(
        session,
        "google",
        next_url="/dashboard",
        prompt_key="authorized-popup",
    )
    return mission.prompt.prompt


@app.post("/login/popup")
def answer_popup():
    result = auth.answer_popup(session, accepted=True)
    if not result.passed:
        return result.message, 400
    return auth.redirect_to_provider(session, url_for("auth_callback", _external=True))


@app.get("/auth/callback")
def auth_callback():
    result = auth.complete_login(session)
    session["user"] = result.identity.to_session()
    session["welcome_badge"] = result.welcome.badge
    return redirect(result.next_url or "/")

Built-In Prompts

default_fun_prompts() currently includes:

  • draw-key: draw a simple key shape
  • conversion-lock: solve a numeric conversion chain
  • operator-conversion-lock: infer the hidden operators between converted numbers
  • number-guess: guess a secret number
  • authorized-popup: acknowledge I'm authorized

Select a prompt when preparing login:

mission = auth.prepare_mission(session, "google", prompt_key="draw-key")

For multiple prompts in sequence, pass prompt_keys instead:

mission = auth.prepare_mission(
    session,
    "google",
    prompt_keys=("authorized-popup", "draw-key", "operator-conversion-lock"),
)

prepare_login(...) remains available as a compatibility alias, but new code should prefer prepare_mission(...). After each successful answer, auth.current_mission(session) returns the next prompt. Once the final prompt passes, use auth.complete_fun(session) for fun-only flows or auth.redirect_to_provider(...) for real provider authentication.

Built-In UI

Funthenticate includes a small renderer and stylesheet for apps that want a nicer default screen without choosing a frontend framework yet.

from flask import Response, session

from funthenticate import FunAuth, default_stylesheet, render_prompt_card

auth = FunAuth()


@app.get("/funthenticate.css")
def funthenticate_css():
    return Response(default_stylesheet(), mimetype="text/css")


@app.get("/login")
def login():
    mission = auth.prepare_mission(
        session,
        next_url="/dashboard",
        prompt_keys=("authorized-popup", "operator-conversion-lock"),
    )
    return render_page(render_prompt_card(mission, action="/login/popup"))


def render_page(card_html: str) -> str:
    return f"""
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="/funthenticate.css">
        <title>Funthenticate</title>
      </head>
      <body>{card_html}</body>
    </html>
    """

Drawing Challenge

The drawing challenge compares the drawn form, not the canvas. It crops the submitted strokes to their own bounding box, rescales them into a normalized grid, centers them, rasterizes the strokes, and compares the result against a template with a tolerance radius.

mission = auth.prepare_mission(session, "google", prompt_key="draw-key")

strokes = [
    [(100, 50), (120, 34), (144, 34), (164, 50), (144, 66), (120, 66), (100, 50)],
    [(164, 50), (256, 50), (256, 68), (274, 68), (274, 50), (294, 50), (294, 74)],
]

result = auth.answer_drawing(session, strokes)

result.score tells the UI how close the drawing was. result.passed decides whether the user may continue to the provider.

Conversion Lock

A conversion lock asks for intermediate answers. Each step can be only an operation, only a conversion, or both at once.

from funthenticate import build_conversion_challenge

challenge = build_conversion_challenge(
    start_value=13,
    steps=[
        {"kind": "add", "operand": 5, "convert_to": "hex"},
        {"kind": "multiply", "operand": 3, "conversion": "bin"},
        {"kind": "subtract", "operand": 7, "base": 8},
    ],
)

result = challenge.evaluate(["0x12", "0b110110", "0o57"])

The same challenge can be attached to a prompt and answered through the session helper:

auth.prepare_mission(session, "google", prompt_key="conversion-lock")
result = auth.answer_conversion(session, ["0x12", "0b110110", "0o57"])

Supported operations:

  • add
  • subtract
  • multiply
  • integer_divide
  • modulo
  • power
  • identity

Supported output bases:

  • decimal: 10, dec, decimal
  • hexadecimal: 16, hex, hexadecimal
  • binary: 2, bin, binary
  • octal: 8, oct, octal

Answers may include prefixes or omit them. For example, both 0x12 and 12 are accepted for a hexadecimal answer.

Operator Guess Mode

Sometimes the user should see only the converted numbers and supply the operators or base conversions that connect them. The user is not allowed to type operands, because +5 gives away too much of the game. Instead, Funthenticate keeps the configured operands hidden, parses moves like + hex, applies the hidden operand to the previous numeric value, and checks whether the computed result lands on the next shown value.

from funthenticate import build_conversion_operator_guess_challenge

challenge = build_conversion_operator_guess_challenge(
    start_value=13,
    steps=[
        {"kind": "add", "operand": 5, "convert_to": "hex"},
        {"kind": "multiply", "operand": 3, "conversion": "bin"},
        {"kind": "subtract", "operand": 7, "base": 8},
    ],
)

assert challenge.display_values() == ("13", "0x12", "0b110110", "0o57")
result = challenge.evaluate(["+ hex", "* bin", "- oct"])

Use the built-in prompt when you want this as the login ritual:

auth.prepare_mission(session, "google", prompt_key="operator-conversion-lock")
result = auth.answer_conversion_operators(session, ["+ hex", "* bin", "- oct"])

Accepted move answers include symbolic and word forms, without numbers:

  • +
  • -
  • *
  • //
  • %
  • **
  • add
  • mul
  • hex
  • bin
  • oct
  • dec
  • + hex
  • mul bin

Number Guessing

The number guessing game is deliberately unfair in a charming way: the secret answer is generated at random inside the configured range when the challenge starts. The user gets a limited number of tries. If they fail, the target is re-rolled, the attempt counter is reset, and the failure message makes it very clear who is to blame.

The target and attempt count live in server-side challenge state instead of the browser session, so every login attempt can have its own little pocket of chance without leaking the answer into client-visible data. The default InMemoryFunStateStore is fine for local demos; production apps should pass a shared store implementation if they run multiple processes or servers.

from funthenticate import FunPrompt, NumberGuessChallenge, PromptDeck

challenge = NumberGuessChallenge(
    key="guess-small",
    range_min=1,
    range_max=5,
    max_tries=3,
)

prompt = FunPrompt(
    key="guess-small",
    title="Guess Small",
    prompt="Guess the secret number.",
    options=(),
    success_message=challenge.success_message,
    failure_message=challenge.failure_message,
    number_guess=challenge,
)

auth = FunAuth(oauth, prompt_deck=PromptDeck((prompt,)))
auth.prepare_mission(session, "google", prompt_key="guess-small")
result = auth.answer_number_guess(session, 4)

Feedback includes:

  • correct
  • too-low
  • too-high

What happens under the hood:

  • If no target exists yet, Funthenticate generates one with secrets.randbelow(...) inside range_min..range_max.
  • Each wrong guess consumes one try and returns a directional hint.
  • If the user runs out of tries, Funthenticate generates a fresh target and resets attempts to 0.
  • The default failure message is intentionally petty: You failed to guess the number in time. This is your failure; the game has reset.

That means the challenge is genuinely up to chance. The user cannot memorize a static answer between failed rounds, because failure burns the old target and starts a new tiny game.

Popup Acknowledgement

For the simplest possible ritual, use authorized-popup:

auth.prepare_mission(session, "google", prompt_key="authorized-popup")
result = auth.answer_popup(session, accepted=True)

The prompt message is I'm authorized. If accepted, the challenge passes.

Custom Multiple-Choice Prompts

The default prompt deck no longer includes multiple-choice prompts, but the building blocks are still available for apps that want them:

from funthenticate import FunPrompt, FunPromptOption, PromptDeck

prompt = FunPrompt(
    key="custom-choice",
    title="Custom Choice",
    prompt="Pick the right option.",
    options=(
        FunPromptOption("yes", "Yes", is_correct=True),
        FunPromptOption("no", "No"),
    ),
    success_message="Correct.",
    failure_message="Try again.",
)

auth = FunAuth(oauth, prompt_deck=PromptDeck((prompt,)))
auth.prepare_mission(session, "google", prompt_key="custom-choice")
result = auth.answer_prompt(session, "yes")

Provider Helpers

Provider helpers require the auth extra:

uv add "funthenticate[auth]"
from funthenticate import google_provider, microsoft_entra_provider

auth.register_provider(google_provider("client-id", "client-secret"))
auth.register_provider(
    microsoft_entra_provider(
        "client-id",
        "client-secret",
        tenant_id="organizations",
    )
)

Security Notes

Funthenticate challenges are not password replacements and not second factors. They are playful gates, not identity proof. Use OIDC, passkeys, MFA, conditional access, and domain restrictions for actual security.

By default, next_url must be a same-site absolute path such as /dashboard. Pass a custom next_url_validator to FunAuth if your app needs stricter routing rules.

For production number-guessing missions, pass a shared FunStateStore implementation instead of relying on the default in-memory store. The store only needs to implement get_number_guess, save_number_guess, and clear_mission, so Redis, a database table, or your web framework's cache all work well.

from funthenticate import FunAuth

auth = FunAuth(state_store=my_shared_store)

Good uses:

  • Make internal tools feel more human
  • Add a low-stakes pre-login ritual
  • Teach users a tiny workflow concept before entering an app
  • Add memorable onboarding around SSO

Bad uses:

  • Replacing OAuth/OIDC
  • Protecting sensitive data with only a drawing or guessing game
  • Treating a popup acknowledgement as identity verification

Development

uv sync
uv run pytest tests -xvs
uv run ruff check .
uv run ruff format .

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

funthenticate-0.1.0.tar.gz (44.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

funthenticate-0.1.0-py3-none-any.whl (31.6 kB view details)

Uploaded Python 3

File details

Details for the file funthenticate-0.1.0.tar.gz.

File metadata

  • Download URL: funthenticate-0.1.0.tar.gz
  • Upload date:
  • Size: 44.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for funthenticate-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6d0b145610dd6aa1bdcb7729cd22113ca89ebb0c64103c95c3dc51fff1c135a9
MD5 6d71ec2b2bf733169f3b2918e1912a34
BLAKE2b-256 f56738aac43a8001029632f7fbc44e0c30778fb6b147cb15f3f6e4d3f747ea1e

See more details on using hashes here.

Provenance

The following attestation bundles were made for funthenticate-0.1.0.tar.gz:

Publisher: python-publish.yml on MichelPhilippin/funthenticate

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file funthenticate-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: funthenticate-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 31.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for funthenticate-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 39a081cea5a269d41284c5c4b135217e7b262652f008336100797e65a031f2c8
MD5 6e3ed742cd8f1787dadfa242284ae064
BLAKE2b-256 87112d0e995ccfbf836960308dfa438ab54307b26a473a985ca825192c390cad

See more details on using hashes here.

Provenance

The following attestation bundles were made for funthenticate-0.1.0-py3-none-any.whl:

Publisher: python-publish.yml on MichelPhilippin/funthenticate

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page