auth-playground 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ import importlib.metadata
2
+ import os
3
+
4
+ from authlib.integrations.flask_client import OAuth
5
+ from authlib.oidc.discovery import get_well_known_url
6
+ from cachelib.simple import SimpleCache
7
+ from flask import Flask
8
+ from flask import g
9
+ from flask import has_request_context
10
+ from flask import redirect
11
+ from flask import request
12
+ from flask import session as flask_session
13
+ from flask import url_for
14
+ from flask_session import Session
15
+
16
+ from auth_playground.endpoints import bp
17
+ from auth_playground.i18n import babel
18
+ from auth_playground.i18n import setup_i18n
19
+ from auth_playground.oauth import bp as oauth_bp
20
+ from auth_playground.session import ServerConfig
21
+
22
+ oauth = OAuth()
23
+ sess = Session()
24
+
25
+
26
+ def is_oauth_configured():
27
+ """Check if OAuth client is configured by checking the registry."""
28
+ return "default" in oauth._registry
29
+
30
+
31
+ def is_oauth_server_from_env(app):
32
+ """Check if OAuth server is set via environment variables."""
33
+ return bool(app.config.get("OAUTH_AUTH_SERVER"))
34
+
35
+
36
+ def is_oauth_client_from_env(app):
37
+ """Check if OAuth client credentials are set via environment variables."""
38
+ return bool(app.config.get("OAUTH_CLIENT_ID"))
39
+
40
+
41
+ def get_oauth_config(app):
42
+ """Get OAuth config from environment or session, with env taking priority."""
43
+ env_config = {
44
+ "client_id": app.config.get("OAUTH_CLIENT_ID"),
45
+ "client_secret": app.config.get("OAUTH_CLIENT_SECRET"),
46
+ "auth_server": app.config.get("OAUTH_AUTH_SERVER"),
47
+ }
48
+
49
+ session_config = {}
50
+ if has_request_context():
51
+ session_config = flask_session.get("oauth_config", {})
52
+ config = {
53
+ **session_config,
54
+ **{k: v for k, v in env_config.items() if v is not None},
55
+ }
56
+
57
+ return config
58
+
59
+
60
+ def setup_oauth(app):
61
+ """Initialize OAuth client with configuration from environment or session."""
62
+ if "default" in oauth._registry:
63
+ del oauth._registry["default"]
64
+
65
+ config = get_oauth_config(app)
66
+ if not config:
67
+ return False
68
+
69
+ oauth.init_app(app)
70
+
71
+ oauth.register(
72
+ name="default",
73
+ client_id=config["client_id"],
74
+ client_secret=config["client_secret"],
75
+ server_metadata_url=get_well_known_url(config["auth_server"], external=True),
76
+ client_kwargs={"scope": "openid profile email phone address groups"},
77
+ )
78
+ return True
79
+
80
+
81
+ def setup_oauth_runtime(app, client_id, client_secret, auth_server):
82
+ """Set up OAuth with runtime configuration stored in session."""
83
+ flask_session["oauth_config"] = {
84
+ "client_id": client_id,
85
+ "client_secret": client_secret,
86
+ "auth_server": auth_server,
87
+ }
88
+
89
+ return setup_oauth(app)
90
+
91
+
92
+ def create_app():
93
+ """Create and configure the Flask application."""
94
+ app = Flask(__name__)
95
+
96
+ app.config["SECRET_KEY"] = os.environ.get(
97
+ "SECRET_KEY", "dev-secret-key-change-in-production"
98
+ )
99
+ app.config["NAME"] = os.environ.get("APP_NAME", "Auth Playground")
100
+
101
+ app.config["SESSION_TYPE"] = "cachelib"
102
+ app.config["SESSION_CACHELIB"] = SimpleCache()
103
+ app.config["SESSION_PERMANENT"] = False
104
+
105
+ app.config["BABEL_DEFAULT_LOCALE"] = "en"
106
+ app.config["BABEL_DEFAULT_TIMEZONE"] = "UTC"
107
+
108
+ oauth_auth_server_env = os.environ.get("OAUTH_AUTH_SERVER")
109
+ app.config["OAUTH_CLIENT_ID"] = os.environ.get("OAUTH_CLIENT_ID")
110
+ app.config["OAUTH_CLIENT_SECRET"] = os.environ.get("OAUTH_CLIENT_SECRET")
111
+ app.config["OAUTH_AUTH_SERVER"] = (
112
+ oauth_auth_server_env.rstrip("/") if oauth_auth_server_env else None
113
+ )
114
+
115
+ sess.init_app(app)
116
+ setup_i18n(app)
117
+
118
+ # Register OAuth blueprint without language prefix (technical endpoints)
119
+ app.register_blueprint(oauth_bp)
120
+
121
+ # Register main blueprint with mandatory language prefix (/<lang:lang_code>/...)
122
+ # The 'lang' converter validates that lang_code is an available language
123
+ app.register_blueprint(bp, url_prefix="/<lang:lang_code>")
124
+
125
+ @app.route("/")
126
+ def root():
127
+ """Redirect to the appropriate language prefix based on browser preference."""
128
+ available_langs = [locale.language for locale in babel.list_translations()]
129
+ default_lang = app.config.get("BABEL_DEFAULT_LOCALE", "en")
130
+ lang = request.accept_languages.best_match(available_langs) or default_lang
131
+ return redirect(url_for("routes.index", lang_code=lang))
132
+
133
+ @app.before_request
134
+ def load_server_config():
135
+ """Load server configuration into flask.g."""
136
+ setup_oauth(app)
137
+
138
+ if not g.get("server_config"):
139
+ g.server_config = ServerConfig.deserialize(flask_session)
140
+ if not g.server_config:
141
+ g.server_config = ServerConfig()
142
+ if issuer_url := app.config.get("OAUTH_AUTH_SERVER"):
143
+ g.server_config.issuer_url = issuer_url
144
+
145
+ @app.context_processor
146
+ def inject_server_info():
147
+ """Inject server information into all templates."""
148
+ pkg_metadata = importlib.metadata.metadata("auth-playground")
149
+ project_urls = dict(
150
+ [url.split(", ", 1) for url in pkg_metadata.get_all("Project-URL") or []]
151
+ )
152
+ return {
153
+ "app_version": importlib.metadata.version("auth-playground"),
154
+ "repository_url": project_urls.get("repository"),
155
+ }
156
+
157
+ return app
auth_playground/cli.py ADDED
@@ -0,0 +1,19 @@
1
+ import os # pragma: no cover
2
+
3
+ from dotenv import load_dotenv # pragma: no cover
4
+
5
+ from auth_playground import create_app # pragma: no cover
6
+
7
+
8
+ def run(): # pragma: no cover
9
+ """Run the Auth Playground application."""
10
+ load_dotenv()
11
+ app = create_app()
12
+ host = os.environ.get("FLASK_RUN_HOST", "0.0.0.0")
13
+ port = int(os.environ.get("FLASK_RUN_PORT", "4000"))
14
+ debug = os.environ.get("FLASK_DEBUG", "True").lower() == "true"
15
+ app.run(host=host, port=port, debug=debug)
16
+
17
+
18
+ if __name__ == "__main__": # pragma: no cover
19
+ run()
@@ -0,0 +1,284 @@
1
+ import requests
2
+ from flask import Blueprint
3
+ from flask import current_app
4
+ from flask import flash
5
+ from flask import g
6
+ from flask import redirect
7
+ from flask import render_template
8
+ from flask import session
9
+ from flask import url_for
10
+ from flask_babel import gettext as _
11
+
12
+ import auth_playground
13
+ from auth_playground.forms import ClientConfigForm
14
+ from auth_playground.forms import DynamicRegistrationForm
15
+ from auth_playground.forms import RefreshTokenForm
16
+ from auth_playground.forms import ServerConfigForm
17
+ from auth_playground.forms import UnregisterClientForm
18
+ from auth_playground.oauth import clear_user_session
19
+ from auth_playground.session import ServerConfig
20
+
21
+ bp = Blueprint("routes", __name__)
22
+
23
+
24
+ def fetch_server_metadata(issuer_url: str, timeout: int = 10) -> tuple[dict, str]:
25
+ """Fetch server metadata from well-known OIDC or OAuth2 endpoints."""
26
+ oidc_url = f"{issuer_url}/.well-known/openid-configuration"
27
+ try:
28
+ response = requests.get(oidc_url, timeout=timeout)
29
+ response.raise_for_status()
30
+ return response.json(), "oidc"
31
+ except requests.exceptions.HTTPError as e:
32
+ if e.response.status_code != 404:
33
+ raise # Only try OAuth2 endpoint if OIDC endpoint is not found
34
+
35
+ oauth2_url = f"{issuer_url}/.well-known/oauth-authorization-server"
36
+ response = requests.get(oauth2_url, timeout=timeout)
37
+ response.raise_for_status()
38
+ return response.json(), "oauth2"
39
+
40
+
41
+ def handle_fetch_metadata_errors(issuer_url: str, on_error):
42
+ """Fetch server metadata and handle errors with custom error handler."""
43
+ try:
44
+ return fetch_server_metadata(issuer_url)
45
+ except requests.exceptions.ConnectionError:
46
+ flash(_("Cannot connect to the server. Please check the URL."), "error")
47
+ return on_error()
48
+ except requests.exceptions.Timeout:
49
+ flash(_("Connection timeout. The server is not responding."), "error")
50
+ return on_error()
51
+ except requests.exceptions.HTTPError as e:
52
+ if e.response.status_code == 404:
53
+ flash(
54
+ _(
55
+ "This server does not support OIDC Discovery or OAuth 2.0 Authorization Server Metadata"
56
+ ),
57
+ "error",
58
+ )
59
+ else:
60
+ flash(
61
+ _("Server returned an error: HTTP {status_code}").format(
62
+ status_code=e.response.status_code
63
+ ),
64
+ "error",
65
+ )
66
+ return on_error()
67
+ except requests.RequestException:
68
+ flash(_("Failed to connect to the server. Please check the URL."), "error")
69
+ return on_error()
70
+ except ValueError:
71
+ flash(_("Invalid response from server"), "error")
72
+ return on_error()
73
+
74
+
75
+ def clear_server_session():
76
+ """Clear all server-related data from the session."""
77
+ clear_user_session()
78
+ ServerConfig.clear(session)
79
+
80
+ try:
81
+ del session["oauth_config"]
82
+ except KeyError:
83
+ pass
84
+
85
+
86
+ @bp.route("/")
87
+ def index():
88
+ """Redirect to the appropriate configuration step or main page."""
89
+ server_configured = auth_playground.is_oauth_server_from_env(current_app) or (
90
+ g.server_config and g.server_config.issuer_url
91
+ )
92
+ client_configured = auth_playground.is_oauth_configured()
93
+
94
+ if not server_configured:
95
+ return redirect(url_for("routes.configure_server"))
96
+
97
+ elif not client_configured:
98
+ return redirect(url_for("routes.configure_client"))
99
+
100
+ return redirect(url_for("routes.playground"))
101
+
102
+
103
+ @bp.route("/server", methods=["GET", "POST"])
104
+ @bp.route("/server/<path:domain>", methods=["GET", "POST"])
105
+ def configure_server(domain=None):
106
+ """Display form to configure and validate identity provider URL."""
107
+ if auth_playground.is_oauth_server_from_env(current_app):
108
+ flash(
109
+ _(
110
+ "OAuth configuration is set via environment variables and cannot be changed"
111
+ ),
112
+ "warning",
113
+ )
114
+ return redirect(url_for("routes.playground"))
115
+
116
+ form = ServerConfigForm()
117
+
118
+ if domain:
119
+ if not domain.startswith(("http://", "https://")):
120
+ domain = f"https://{domain}"
121
+ issuer_url = domain.rstrip("/")
122
+
123
+ elif not form.validate_on_submit():
124
+ if g.server_config and g.server_config.issuer_url:
125
+ flash(_("You can now configure a different identity provider"), "info")
126
+ clear_server_session()
127
+ g.server_config = ServerConfig()
128
+ return render_template("configure_server.html", form=form)
129
+
130
+ else:
131
+ issuer_url = form.issuer_url.data.rstrip("/")
132
+
133
+ result = handle_fetch_metadata_errors(
134
+ issuer_url, lambda: render_template("configure_server.html", form=form)
135
+ )
136
+ if not isinstance(result, tuple):
137
+ return result
138
+ metadata, server_type = result
139
+
140
+ g.server_config = ServerConfig(
141
+ metadata=metadata,
142
+ issuer_url=issuer_url,
143
+ server_type=server_type,
144
+ )
145
+ g.server_config.save(session)
146
+
147
+ flash(_("Server metadata successfully loaded"), "success")
148
+
149
+ return redirect(url_for("routes.configure_client"))
150
+
151
+
152
+ @bp.route("/client", methods=["GET", "POST"])
153
+ def configure_client():
154
+ """Display options to configure OAuth client credentials."""
155
+ if auth_playground.is_oauth_client_from_env(current_app):
156
+ flash(
157
+ _("OAuth client is set via environment variables and cannot be changed"),
158
+ "warning",
159
+ )
160
+ return redirect(url_for("routes.playground"))
161
+
162
+ issuer_url = g.server_config.issuer_url
163
+ if not issuer_url:
164
+ flash(_("Please configure a server"), "warning")
165
+ return redirect(url_for("routes.configure_server"))
166
+
167
+ if not g.server_config.metadata:
168
+ result = handle_fetch_metadata_errors(
169
+ issuer_url, lambda: redirect(url_for("routes.configure_server"))
170
+ )
171
+ if not isinstance(result, tuple):
172
+ return result
173
+ metadata, server_type = result
174
+ g.server_config.metadata = metadata
175
+ g.server_config.issuer_url = issuer_url.rstrip("/")
176
+ g.server_config.server_type = server_type
177
+ g.server_config.save(session)
178
+
179
+ client_form = ClientConfigForm()
180
+ dynamic_registration_form = DynamicRegistrationForm()
181
+
182
+ if not client_form.validate_on_submit():
183
+ return render_template(
184
+ "configure_client.html",
185
+ client_form=client_form,
186
+ dynamic_registration_form=dynamic_registration_form,
187
+ )
188
+
189
+ auth_playground.setup_oauth_runtime(
190
+ current_app,
191
+ client_form.client_id.data,
192
+ client_form.client_secret.data,
193
+ issuer_url,
194
+ )
195
+ flash(_("OAuth configuration completed successfully"), "success")
196
+ return redirect(url_for("routes.playground"))
197
+
198
+
199
+ @bp.route("/playground")
200
+ def playground():
201
+ """Display the main playground page with OAuth 2.0 demonstration controls."""
202
+ server_configured = auth_playground.is_oauth_server_from_env(current_app) or (
203
+ g.server_config and g.server_config.issuer_url
204
+ )
205
+ client_configured = auth_playground.is_oauth_configured()
206
+
207
+ if not server_configured:
208
+ return redirect(url_for("routes.configure_server"))
209
+
210
+ elif not client_configured:
211
+ return redirect(url_for("routes.configure_client"))
212
+
213
+ if g.server_config and not g.server_config.metadata:
214
+ issuer_url = current_app.config.get("OAUTH_AUTH_SERVER")
215
+ if issuer_url:
216
+ try:
217
+ metadata, server_type = fetch_server_metadata(issuer_url)
218
+ g.server_config.metadata = metadata
219
+ g.server_config.server_type = server_type
220
+ if not current_app.config.get("OAUTH_AUTH_SERVER"):
221
+ g.server_config.save(session)
222
+ except Exception:
223
+ pass
224
+
225
+ refresh_form = RefreshTokenForm()
226
+ unregister_form = UnregisterClientForm()
227
+ return render_template(
228
+ "playground.html",
229
+ refresh_form=refresh_form,
230
+ unregister_form=unregister_form,
231
+ )
232
+
233
+
234
+ @bp.route("/specs")
235
+ def specs():
236
+ """Display server specifications."""
237
+ if not g.server_config or not g.server_config.specs:
238
+ flash(_("No server configured"), "warning")
239
+ return redirect(url_for("routes.configure_server"))
240
+
241
+ server_specs = g.server_config.specs
242
+ supported_specs = [
243
+ (
244
+ spec,
245
+ server_specs.get_spec_display_name(spec),
246
+ server_specs.get_spec_url(spec),
247
+ )
248
+ for spec in server_specs.get_supported_specs()
249
+ ]
250
+ unsupported_specs = [
251
+ (
252
+ spec,
253
+ server_specs.get_spec_display_name(spec),
254
+ server_specs.get_spec_url(spec),
255
+ )
256
+ for spec in server_specs.get_unsupported_specs()
257
+ ]
258
+ unknown_specs = [
259
+ (
260
+ spec,
261
+ server_specs.get_spec_display_name(spec),
262
+ server_specs.get_spec_url(spec),
263
+ )
264
+ for spec in server_specs.get_unknown_specs()
265
+ ]
266
+
267
+ return render_template(
268
+ "specs.html",
269
+ supported_specs=supported_specs,
270
+ unsupported_specs=unsupported_specs,
271
+ unknown_specs=unknown_specs,
272
+ )
273
+
274
+
275
+ @bp.route("/tos")
276
+ def tos():
277
+ """Display the Terms of Service page."""
278
+ return render_template("tos.html")
279
+
280
+
281
+ @bp.route("/policy")
282
+ def policy():
283
+ """Display the Privacy Policy page."""
284
+ return render_template("policy.html")
@@ -0,0 +1,91 @@
1
+ from flask import current_app
2
+ from flask_babel import lazy_gettext as _
3
+ from flask_wtf import FlaskForm
4
+ from wtforms import PasswordField
5
+ from wtforms import StringField
6
+ from wtforms import SubmitField
7
+ from wtforms import ValidationError
8
+ from wtforms.validators import DataRequired
9
+ from wtforms.validators import Length
10
+
11
+
12
+ class RefreshTokenForm(FlaskForm):
13
+ """Form to refresh access token using refresh token."""
14
+
15
+ submit = SubmitField(_("Renew tokens"))
16
+
17
+
18
+ def validate_issuer_url(form, field):
19
+ """Validate issuer URL with relaxed rules in debug/testing mode."""
20
+ url = field.data
21
+
22
+ if not url.startswith(("http://", "https://")):
23
+ raise ValidationError(_("URL must start with http:// or https://"))
24
+
25
+ is_debug = current_app.debug
26
+ is_testing = current_app.testing
27
+
28
+ if url.startswith("http://") and not (is_debug or is_testing): # pragma: no cover
29
+ raise ValidationError(
30
+ _("HTTP is only allowed in debug or testing mode. Use HTTPS in production.")
31
+ )
32
+
33
+
34
+ class ServerConfigForm(FlaskForm):
35
+ """Form to configure the provider server URL."""
36
+
37
+ issuer_url = StringField(
38
+ _("Provider URL:"),
39
+ validators=[
40
+ DataRequired(message=_("Provider URL is required")),
41
+ validate_issuer_url,
42
+ ],
43
+ description=_(
44
+ "Enter the base URL of your OIDC/OAuth2 provider (e.g., https://auth.example.com)"
45
+ ),
46
+ render_kw={"placeholder": "https://auth.example.com", "type": "url"},
47
+ )
48
+ submit = SubmitField(_("Continue"))
49
+
50
+
51
+ class ClientConfigForm(FlaskForm):
52
+ """Form to configure OAuth client credentials manually."""
53
+
54
+ client_id = StringField(
55
+ _("Client ID:"),
56
+ validators=[
57
+ DataRequired(message=_("Client ID is required")),
58
+ Length(
59
+ min=1,
60
+ max=255,
61
+ message=_("Client ID must be between 1 and 255 characters"),
62
+ ),
63
+ ],
64
+ render_kw={"placeholder": "auth-playground-client"},
65
+ )
66
+ client_secret = PasswordField(
67
+ _("Client secret:"),
68
+ validators=[
69
+ DataRequired(message=_("Client Secret is required")),
70
+ Length(min=1, message=_("Client Secret is required")),
71
+ ],
72
+ render_kw={"placeholder": "******************"},
73
+ )
74
+ submit = SubmitField(_("Complete configuration"))
75
+
76
+
77
+ class DynamicRegistrationForm(FlaskForm):
78
+ """Form to trigger dynamic client registration with CSRF protection."""
79
+
80
+ initial_access_token = StringField(
81
+ _("Initial access token:"),
82
+ validators=[],
83
+ render_kw={"placeholder": _("Leave empty if not required")},
84
+ )
85
+ submit = SubmitField(_("Register client"))
86
+
87
+
88
+ class UnregisterClientForm(FlaskForm):
89
+ """Form to trigger client unregistration with CSRF protection."""
90
+
91
+ submit = SubmitField(_("Unregister client"))
@@ -0,0 +1,76 @@
1
+ from flask import g
2
+ from flask_babel import Babel
3
+ from flask_babel import get_locale
4
+ from werkzeug.exceptions import NotFound
5
+ from werkzeug.routing import BaseConverter
6
+
7
+ babel = Babel()
8
+
9
+
10
+ class LangConverter(BaseConverter):
11
+ """URL converter that validates language codes against available translations."""
12
+
13
+ def __init__(self, map, *args, **kwargs):
14
+ super().__init__(map, *args, **kwargs)
15
+ self.regex = r"[a-z]{2,3}"
16
+
17
+ def to_python(self, value):
18
+ """Convert URL value to Python, validating against available translations.
19
+
20
+ Raises 404 if language code is not available.
21
+ """
22
+ available_langs = [locale.language for locale in babel.list_translations()]
23
+
24
+ if value not in available_langs:
25
+ raise NotFound(
26
+ f"Language '{value}' is not available. "
27
+ f"Available languages: {', '.join(available_langs)}"
28
+ )
29
+
30
+ return value
31
+
32
+ def to_url(self, value):
33
+ """Convert Python value to URL."""
34
+ return value
35
+
36
+
37
+ def setup_i18n(app):
38
+ @app.url_defaults
39
+ def add_language_code(endpoint, values):
40
+ """Automatically inject language code into url_for() calls when using i18n routes."""
41
+ if endpoint and endpoint.startswith("oauth."):
42
+ return
43
+
44
+ if not g.get("lang_code"):
45
+ g.lang_code = app.config.get("BABEL_DEFAULT_LOCALE", "en")
46
+
47
+ if "lang_code" not in values:
48
+ values.setdefault("lang_code", g.lang_code)
49
+
50
+ @app.url_value_preprocessor
51
+ def pull_lang_code(endpoint, values):
52
+ """Extract language code from URL and store in g.lang_code for i18n routes."""
53
+ if values is not None and "lang_code" in values:
54
+ g.lang_code = values.pop("lang_code")
55
+
56
+ @app.before_request
57
+ def before_request():
58
+ g.available_languages = babel.list_translations()
59
+ g.available_language_codes = [
60
+ locale.language for locale in g.available_languages
61
+ ]
62
+
63
+ @app.context_processor
64
+ def global_processor():
65
+ return {
66
+ "locale": get_locale(),
67
+ "available_languages": getattr(g, "available_languages", []),
68
+ }
69
+
70
+ def locale_selector():
71
+ # Language is always specified in URL prefix (mandatory)
72
+ return g.get("lang_code", app.config["BABEL_DEFAULT_LOCALE"])
73
+
74
+ babel.init_app(app, locale_selector=locale_selector)
75
+ app.url_map.converters["lang"] = LangConverter
76
+ app.jinja_env.policies["ext.i18n.trimmed"] = True