diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd8b32a --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Flask secret key — generate with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=replace_with_random_secret + +# Authentik base URL (no trailing slash) +AUTHENTIK_URL=https://auth.example.com + +# The slug shown in Authentik under Applications → → Slug +AUTHENTIK_APP_SLUG=my-app + +# OAuth2 credentials from Authentik → Providers → +AUTHENTIK_CLIENT_ID=your_client_id_here +AUTHENTIK_CLIENT_SECRET=your_client_secret_here + diff --git a/.gitignore b/.gitignore index 57f1cb2..97fdf77 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ -/.idea/ \ No newline at end of file +/.idea/ +.env +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +instance/ +INFO.md \ No newline at end of file diff --git a/app.py b/app.py index 5d20a01..6e10fc4 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,97 @@ -from flask import Flask +# Arian Nasr +# 2026-02-25 + +import os +import functools +from flask import Flask, render_template, request, redirect, url_for, session +from authlib.integrations.flask_client import OAuth # noqa +from dotenv import load_dotenv + +load_dotenv() app = Flask(__name__) +# ── Security ────────────────────────────────────────────────────────────────── +app.secret_key = os.environ.get("SECRET_KEY", "CHANGE_ME_IN_PRODUCTION") +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +# Set SESSION_COOKIE_SECURE = True when serving over HTTPS -@app.route('/') -def hello_world(): # put application's code here - return 'Hello World!' +# ── Authentik OIDC config ───────────────────────────────────────────────────── +AUTHENTIK_URL = os.environ.get("AUTHENTIK_URL", "https://auth.example.com") +AUTHENTIK_APP_SLUG = os.environ.get("AUTHENTIK_APP_SLUG", "my-app") + +oauth = OAuth(app) +oauth.register( + name="authentik", + client_id=os.environ.get("AUTHENTIK_CLIENT_ID"), + client_secret=os.environ.get("AUTHENTIK_CLIENT_SECRET"), + # Authentik exposes a per-application OIDC discovery document + server_metadata_url=( + f"{AUTHENTIK_URL}/application/o/{AUTHENTIK_APP_SLUG}/.well-known/openid-configuration" + ), + client_kwargs={"scope": "openid email profile"}, +) -if __name__ == '__main__': - app.run() +# ── Helpers ─────────────────────────────────────────────────────────────────── +def login_required(f): + """Decorator that redirects unauthenticated users to /login.""" + @functools.wraps(f) + def decorated(*args, **kwargs): + if not session.get("user"): + return redirect(url_for("login", next=request.url)) + return f(*args, **kwargs) + return decorated + + +# ── Routes ──────────────────────────────────────────────────────────────────── +@app.route("/") +def index(): + user = session.get("user") + return render_template("index.html", user=user) + + +@app.route("/login") +def login(): + redirect_uri = url_for("callback", _external=True) + return oauth.authentik.authorize_redirect(redirect_uri) + + +@app.route("/callback") +def callback(): + token = oauth.authentik.authorize_access_token() + # authlib parses and verifies the ID token automatically; + # userinfo claims are available directly on the token dict. + user = token.get("userinfo") + if user is None: + # Fall back to the userinfo endpoint if claims aren't in the token + user = oauth.authentik.userinfo() + session["user"] = dict(user) + # Honour the 'next' redirect set by login_required, defaulting to home + next_url = request.args.get("next") or url_for("index") + return redirect(next_url) + + +@app.route("/logout") +def logout(): + session.clear() + # Redirect to Authentik's end-session endpoint so the SSO session is + # also terminated. The post_logout_redirect_uri must be registered in + # the Authentik provider settings. + end_session_url = ( + f"{AUTHENTIK_URL}/application/o/{AUTHENTIK_APP_SLUG}/end-session/" + ) + return redirect(end_session_url) + + +# ── Protected example ───────────────────────────────────────────────────────── +@app.route("/dashboard") +@login_required +def dashboard(): + """Example of a route that requires authentication.""" + return render_template("dashboard.html", user=session["user"]) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40ef53d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.3 +Authlib==1.6.8 +requests==2.32.5 +python-dotenv==1.0.1 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d6117e5 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {% block title %}BoxDrop{% endblock %} + + + + + + {% block content %}{% endblock %} + + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..367d705 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Dashboard – BoxDrop{% endblock %} + +{% block content %} +

Dashboard

+

This page is only visible to authenticated users.

+ + + + + + + + + + {% for key, value in user.items() %} + + + + + {% endfor %} + +
ClaimValue
{{ key }}{{ value }}
+{% endblock %} + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..82a6e7c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Home – BoxDrop{% endblock %} + +{% block content %} + {% if user %} +

Welcome back, {{ user.get('name') or user.get('email') }}! 👋

+

You are authenticated via Authentik.

+

Go to Dashboard

+ +
+ Raw token claims +
{{ user | tojson(indent=2) }}
+
+ {% else %} +

BoxDrop

+

Please log in to continue.

+

Log in with Authentik

+ {% endif %} +{% endblock %} +