From 083818ed71209558066aabf4e0606b92c32428df Mon Sep 17 00:00:00 2001 From: mrunankpawar Date: Mon, 24 Aug 2026 09:43:05 -0700 Subject: [PATCH 1/7] feat(app): add OIDC federated app sign-in (start/exchange_token) Adds descope_client.app.start()/exchange_token() for OIDC Federated Apps configured in the Descope Console, with Descope acting as the IDP. - start() builds the authorize URL (and a fresh PKCE pair) locally, with no network call - the authorize endpoint 303-redirects rather than returning JSON. - exchange_token() does the real code->token exchange as a standard OAuth2 client (form-encoded body, no Descope bearer header), returning the raw OAuth2/OIDC token shape. - flow + login_hint support a "homegrown first factor, Descope for MFA only" integration: run only an MFA-only Descope Flow for an already-identified user. See samples/app_oidc_mfa_sample_app.py for a full runnable example. Verified end-to-end against a live confidential-client test app, including a full round trip through two real logins (default flow and a console-edited magic-link MFA flow) and real token exchange - see AppBase's docstring for exactly what was confirmed live vs. what's still open (public-client/PKCE-only path, redirect_uri validation timing, non-email login_hint). Only OIDC federated apps are supported; SAML/WS-Fed federated apps are IDP-initiated with no code/token/exchange_token step at all and are out of scope here. Co-Authored-By: Claude Sonnet 5 --- README.md | 25 ++++ descope/authmethod/_app_base.py | 160 ++++++++++++++++++++++ descope/authmethod/app.py | 120 ++++++++++++++++ descope/authmethod/app_async.py | 76 +++++++++++ descope/common.py | 2 + descope/descope_client.py | 6 + descope/descope_client_async.py | 6 + samples/app_oidc_mfa_sample_app.py | 128 +++++++++++++++++ samples/app_sample_app.py | 35 +++++ tests/test_app.py | 211 +++++++++++++++++++++++++++++ 10 files changed, 769 insertions(+) create mode 100644 descope/authmethod/_app_base.py create mode 100644 descope/authmethod/app.py create mode 100644 descope/authmethod/app_async.py create mode 100644 samples/app_oidc_mfa_sample_app.py create mode 100644 samples/app_sample_app.py create mode 100644 tests/test_app.py diff --git a/README.md b/README.md index 4eb774f67..aa2c04bea 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,31 @@ The session and refresh JWTs should be returned to the caller, and passed with e Note: the descope_client.saml.start(..) and descope_client.saml.exchange_token(..) functions are DEPRECATED, use the above sso functions instead +### App (Federated Apps) + +Plug an [OIDC Federated App](https://docs.descope.com/identity-federation/applications) configured on the [Descope console](https://app.descope.com/applications) into a pre-existing/homegrown login process, with Descope acting as the IDP - a real OAuth2 authorize/token pair. (The console also supports SAML and WS-Fed federated apps, but those are IDP-initiated with no code, no token, and no `exchange_token` step at all - a fundamentally different shape this SDK doesn't cover; only OIDC is supported here.) + +```python +resp = descope_client.app.start( + app_id="my-federated-app-id", # The Federated App ID from the Descope console + return_url="https://my-app.com/callback", # must match a redirect URI registered on the app + login_hint="user@example.com", # Optional hint about the user's login identifier +) +# Persist resp["state"] and resp["code_verifier"] (e.g. server-side session) until the callback, +# then redirect the browser to resp["url"]. This call makes no network request - it just builds +# the URL (and a fresh PKCE pair) locally, since the authorize endpoint 303-redirects rather +# than returning JSON. + +# On the callback (code arrives as a query param): +jwt_response = descope_client.app.exchange_token(app_id, code, code_verifier=resp["code_verifier"]) +# jwt_response is the raw OAuth2/OIDC token shape (access_token, id_token, refresh_token, ...) - +# not this SDK's usual sessionJwt/refreshJwt shape. +``` + +`start` always generates a PKCE pair as a safe default (covers apps configured as public or unspecified OAuth clients). If the app is configured as a confidential client instead, also pass `client_secret` to `exchange_token`. This whole path - `start`, a real login, and `exchange_token` - has been confirmed end-to-end against a live confidential-client test app. + +For a "homegrown first factor, Descope for MFA only" integration, pass `flow` to pick which Descope Flow the login page runs (overriding the app's console default) along with `login_hint` set to the user your own backend already identified - see `samples/app_oidc_mfa_sample_app.py` for a full runnable example. + ### TOTP Authentication The user can authenticate using an authenticator app, such as Google Authenticator. diff --git a/descope/authmethod/_app_base.py b/descope/authmethod/_app_base.py new file mode 100644 index 000000000..5a54df1eb --- /dev/null +++ b/descope/authmethod/_app_base.py @@ -0,0 +1,160 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +import hashlib +import secrets +from base64 import b64encode as base64encode +from base64 import urlsafe_b64encode +from typing import Dict, Optional +from urllib.parse import urlencode + +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class AppBase: + """Shared, I/O-free base for the Federated App auth-method classes. + + Holds only static validation guards and URL/param composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer (used only by + ``exchange_token``): + + - ``App(AppBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``AppAsync(AppBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + + A "Federated App" is configured in the Descope Console and represents a + homegrown/third-party application that delegates its sign-in flow to Descope, with + Descope acting as the IDP - a real OAuth2 authorize/token pair, confirmed live against a + real project's ".well-known/openid-configuration": ``/oauth2/v1/{project_id}/authorize`` + and ``/oauth2/v1/{project_id}/token``, with ``client_id`` set to the app's dedicated OIDC + client ID: ``base64(f"{project_id}:{app_id}")``, padded with trailing ``#``/``##`` so the + encoding needs no ``=`` (verified byte-for-byte against a live console-issued + ``clientId``; see ``_build_oidc_client_id``). + + ``start`` never calls the network: the authorize endpoint 303-redirects rather than + returning JSON, so this just builds that URL (and a fresh PKCE pair) locally and hands it + back for you to redirect the browser to. + + ``flow`` picks which Descope Flow the login page runs, overriding the app's console + default (confirmed live: passing an explicit ``flow`` value changes the login page's own + ``flow`` query param accordingly). This is how to build a "homegrown first factor, + Descope for MFA only" integration: your own backend handles the first factor, then calls + ``start`` with ``flow`` set to whatever your own MFA-only flow's ID is (or leave it unset + if the app's console-configured default flow is already that MFA flow), and + ``login_hint`` set to the already-identified user - confirmed live to arrive at the login + page as ``oidc_login_hint``. There is no session-based shortcut available here: OIDC's + other step-up mechanism (an ``su`` claim carried on Descope's own short-lived "DS" + session cookie, forwarded via ``oidc-su-session``) requires the browser to already hold a + Descope-issued session from some prior Descope-native auth - it doesn't apply when the + first factor never touches Descope at all, which is exactly this case. + + CONFIRMED LIVE, full round trip, against a real confidential-client test app + (project P3I9XNBUps4jHk4ybbaezDSu7mjH, app SA3I9XPZkJYkcCwN34D77fNpGL1D9): ``start``'s + URL 303-redirected to Descope's hosted login page with the expected ``sso_app_id``; after + completing that login (twice - once against the default flow, once against a + console-edited magic-link MFA flow) and pasting back the resulting ``code``, + ``exchange_token`` (with both ``code_verifier`` and ``client_secret`` supplied) returned + real ``access_token``/``refresh_token``/``id_token`` JWTs with `expires_in: 600`. So for a + confidential client, PKCE + client_secret together are accepted (the client_secret is + what's actually required for this client type; PKCE was extra and harmless). Passing a + ``client_secret`` on a public client, or omitting it on a confidential one, is not yet + tested. Also not yet tested: redirect_uri validation (an unregistered redirect_uri did + not block the initial authorize redirect in testing, which suggests it's checked later, + right before the post-login redirect back - not confirmed). + + One thing is still defaulted rather than known per-app up front: whether a *given* app + requires PKCE (public client) vs a client secret (confidential) vs either (unspecified) - + ``app_id`` alone doesn't say which, so ``start`` always generates a PKCE pair regardless + (confirmed harmless above for a confidential app); pass the resulting ``code_verifier`` + through to ``exchange_token`` either way, and add ``client_secret`` if the app turns out + to be confidential. The discovery doc lists both ``client_secret_basic`` and + ``client_secret_post`` as supported; this SDK uses the latter (secret in the POST body, + not a Basic auth header) - the live test above confirms that choice works. + """ + + @staticmethod + def _validate_app_id(app_id: Optional[str]) -> None: + if not app_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "App ID cannot be empty") + + @staticmethod + def _validate_return_url(return_url: Optional[str]) -> None: + if not return_url: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "return_url is required (it must match a redirect URI registered on the app " + "in the Descope Console)", + ) + + @staticmethod + def _generate_random_token(nbytes: int = 32) -> str: + return secrets.token_urlsafe(nbytes) + + @staticmethod + def _generate_pkce_pair() -> tuple: + """Returns (code_verifier, code_challenge) - RFC 7636, S256 method.""" + code_verifier = secrets.token_urlsafe(64)[:128] + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + code_challenge = urlsafe_b64encode(digest).decode("ascii").rstrip("=") + return code_verifier, code_challenge + + @staticmethod + def _build_oidc_client_id(project_id: str, app_id: str) -> str: + """Replicates the backend's ``BuildApplicationClientID`` - verified to reproduce a + real console-issued ``clientId`` byte-for-byte. Standard (not URL-safe) base64, + padded with ``#``/``##`` before encoding so the output needs no ``=``.""" + raw = f"{project_id}:{app_id}" + pad = {1: "##", 2: "#"}.get(len(raw) % 3, "") + return base64encode((raw + pad).encode("ascii")).decode("ascii") + + @staticmethod + def _compose_oidc_authorize_url( + base_url: str, + project_id: str, + app_id: str, + return_url: str, + tenant: str, + login_hint: str, + scope: str, + state: str, + code_challenge: str, + flow: str, + ) -> str: + params: Dict[str, str] = { + "response_type": "code", + "client_id": AppBase._build_oidc_client_id(project_id, app_id), + "redirect_uri": return_url, + "scope": scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + if tenant: + params["tenant"] = tenant + if login_hint: + params["login_hint"] = login_hint + if flow: + params["flow"] = flow + return f"{base_url}/oauth2/v1/{project_id}/authorize?{urlencode(params)}" + + @staticmethod + def _compose_oidc_token_body( + project_id: str, + app_id: str, + code: str, + code_verifier: str, + client_secret: str, + redirect_uri: str, + ) -> Dict[str, str]: + body: Dict[str, str] = { + "grant_type": "authorization_code", + "code": code, + "client_id": AppBase._build_oidc_client_id(project_id, app_id), + } + if code_verifier: + body["code_verifier"] = code_verifier + if client_secret: + body["client_secret"] = client_secret + if redirect_uri: + body["redirect_uri"] = redirect_uri + return body diff --git a/descope/authmethod/app.py b/descope/authmethod/app.py new file mode 100644 index 000000000..eb9a90592 --- /dev/null +++ b/descope/authmethod/app.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Optional + +import httpx + +from descope._authmethod_base import AuthMethodBase +from descope.authmethod._app_base import AppBase +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, ERROR_TYPE_SERVER_ERROR, AuthException + + +class App(AppBase, AuthMethodBase): + def start( + self, + app_id: str, + return_url: str, + tenant: Optional[str] = None, + login_hint: Optional[str] = None, + scope: Optional[str] = None, + state: Optional[str] = None, + flow: Optional[str] = None, + ) -> dict: + """ + Build the sign-in redirect URL for an OIDC Federated App. + + This makes no network call: the authorize endpoint 303-redirects rather than + returning JSON, so this just builds the URL (and a fresh PKCE pair) locally. See + ``AppBase`` for the full explanation and the live round-trip confirmation. + + Args: + app_id (str): The Federated App ID (as configured in the Descope Console) + return_url (str): Must match a redirect URI registered on the app in the console. + tenant (str, optional): Tenant ID or name, for apps scoped to a specific tenant + login_hint (str, optional): Hint about the user's login identifier + scope (str, optional): Defaults to "openid" + state (str, optional): Defaults to a generated random value (returned back to you + either way, so you can verify it on the callback) + flow (str, optional): Which Descope Flow the login page runs, overriding the + app's console default. Use this for a "homegrown first factor, Descope for + MFA only" integration: pass your own MFA-only flow's ID along with + ``login_hint`` set to the already-identified user - confirmed live to reach + the login page as ``oidc_login_hint``. Leave unset if the app's console + default flow is already the MFA flow you want. See ``AppBase`` for why the + session-cookie-based step-up shortcut doesn't apply when the first factor + never touches Descope. + + Return value (dict): ``{'url': ..., 'state': ..., 'code_verifier': ...}`` - hold onto + ``state`` and ``code_verifier`` and pass them to ``exchange_token``. + """ + self._validate_app_id(app_id) + self._validate_return_url(return_url) + + code_verifier, code_challenge = self._generate_pkce_pair() + resolved_state = state if state else self._generate_random_token() + url = self._compose_oidc_authorize_url( + self._http.base_url, + self._auth.project_id, + app_id, + return_url, + tenant if tenant else "", + login_hint if login_hint else "", + scope if scope else "openid", + resolved_state, + code_challenge, + flow if flow else "", + ) + return {"url": url, "state": resolved_state, "code_verifier": code_verifier} + + def exchange_token( + self, + app_id: str, + code: str, + code_verifier: Optional[str] = None, + client_secret: Optional[str] = None, + redirect_uri: Optional[str] = None, + ) -> dict: + """ + Exchange a Federated App authorization code for tokens. + + CONFIRMED LIVE end-to-end: a real login through the URL from ``start``, followed by + this call with the resulting code, code_verifier, and the app's client_secret, + returned real access/refresh/ID tokens (see ``AppBase`` for the details). This + bypasses the SDK's normal HTTP layer deliberately - the token endpoint is a standard + OAuth2 endpoint (form-encoded body, client credentials in the body, no Descope bearer + header), confirmed working via ``client_secret_post`` (secret in the body, per the + project's discovery document). + + Args: + app_id (str): The Federated App ID passed to ``start`` + code (str): The authorization code from the redirect callback + code_verifier (str, optional): The value ``start`` returned - pass it even for a + confidential app (harmless extra; confirmed live alongside client_secret) + client_secret (str, optional): Required if the app is a confidential client + redirect_uri (str, optional): Must match the return_url passed to ``start`` + + Returns dict in the raw OAuth2/OIDC token shape (access_token, token_type, + refresh_token, id_token, expires_in, scope) - not this SDK's usual session shape. + """ + self._validate_app_id(app_id) + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "code cannot be empty") + + body = self._compose_oidc_token_body( + self._auth.project_id, + app_id, + code, + code_verifier if code_verifier else "", + client_secret if client_secret else "", + redirect_uri if redirect_uri else "", + ) + response = httpx.post( + f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token", + data=body, + follow_redirects=False, + verify=self._http.client_verify, + timeout=self._http.timeout_seconds, + ) + if response.status_code >= 400: + raise AuthException(response.status_code, ERROR_TYPE_SERVER_ERROR, response.text) + return response.json() diff --git a/descope/authmethod/app_async.py b/descope/authmethod/app_async.py new file mode 100644 index 000000000..127c09861 --- /dev/null +++ b/descope/authmethod/app_async.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Optional + +from descope._authmethod_base import AsyncAuthMethodBase +from descope.authmethod._app_base import AppBase +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, ERROR_TYPE_SERVER_ERROR, AuthException + + +class AppAsync(AppBase, AsyncAuthMethodBase): + """Async Federated App (OIDC only - see AppBase) auth-method. ``start`` is I/O-free but + stays ``async def`` for a consistent call shape; ``exchange_token`` does a real + coroutine-based network call.""" + + async def start( + self, + app_id: str, + return_url: str, + tenant: Optional[str] = None, + login_hint: Optional[str] = None, + scope: Optional[str] = None, + state: Optional[str] = None, + flow: Optional[str] = None, + ) -> dict: + """Build the sign-in redirect URL for an OIDC Federated App; see ``App.start`` (the + sync equivalent) for the full explanation.""" + self._validate_app_id(app_id) + self._validate_return_url(return_url) + + code_verifier, code_challenge = self._generate_pkce_pair() + resolved_state = state if state else self._generate_random_token() + url = self._compose_oidc_authorize_url( + self._http.base_url, + self._auth.project_id, + app_id, + return_url, + tenant if tenant else "", + login_hint if login_hint else "", + scope if scope else "openid", + resolved_state, + code_challenge, + flow if flow else "", + ) + return {"url": url, "state": resolved_state, "code_verifier": code_verifier} + + async def exchange_token( + self, + app_id: str, + code: str, + code_verifier: Optional[str] = None, + client_secret: Optional[str] = None, + redirect_uri: Optional[str] = None, + ) -> dict: + """Exchange a Federated App authorization code for tokens; see + ``App.exchange_token`` (the sync equivalent) for the full explanation - confirmed + with a live end-to-end test.""" + self._validate_app_id(app_id) + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "code cannot be empty") + + body = self._compose_oidc_token_body( + self._auth.project_id, + app_id, + code, + code_verifier if code_verifier else "", + client_secret if client_secret else "", + redirect_uri if redirect_uri else "", + ) + response = await self._http._async_client.post( + f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token", + data=body, + follow_redirects=False, + ) + if response.status_code >= 400: + raise AuthException(response.status_code, ERROR_TYPE_SERVER_ERROR, response.text) + return response.json() diff --git a/descope/common.py b/descope/common.py index 946eb7cb1..0541f6fb4 100644 --- a/descope/common.py +++ b/descope/common.py @@ -74,6 +74,8 @@ class EndpointsV1: auth_sso_start_path = "/v1/auth/sso/authorize" sso_exchange_token_path = "/v1/auth/sso/exchange" + # app (federated apps - oidc only; see AppBase for why saml/wsfed aren't supported) + # totp sign_up_auth_totp_path = "/v1/auth/totp/signup" verify_totp_path = "/v1/auth/totp/verify" diff --git a/descope/descope_client.py b/descope/descope_client.py index 8f39159f0..6e1516291 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -8,6 +8,7 @@ from descope._client_base import DescopeClientBase from descope.auth import Auth +from descope.authmethod.app import App # noqa: F401 from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401 from descope.authmethod.magiclink import MagicLink # noqa: F401 from descope.authmethod.oauth import OAuth # noqa: F401 @@ -74,6 +75,7 @@ def __init__( self._oauth = OAuth(self._auth) self._saml = SAML(self._auth) # deprecated self._sso = SSO(self._auth) + self._app = App(self._auth) self._otp = OTP(self._auth) self._totp = TOTP(self._auth) self._webauthn = WebAuthn(self._auth) @@ -166,6 +168,10 @@ def saml(self): def sso(self): return self._sso + @property + def app(self): + return self._app + @property def webauthn(self): return self._webauthn diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 1da2a94ad..f7136ffc9 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -9,6 +9,7 @@ from descope._client_base import DescopeClientBase from descope.auth_async import AuthAsync +from descope.authmethod.app_async import AppAsync from descope.authmethod.enchantedlink_async import EnchantedLinkAsync from descope.authmethod.magiclink_async import MagicLinkAsync from descope.authmethod.oauth_async import OAuthAsync @@ -121,6 +122,7 @@ def __init__( self._oauth = OAuthAsync(self._auth) self._saml = SAMLAsync(self._auth) # deprecated self._sso = SSOAsync(self._auth) + self._app = AppAsync(self._auth) self._otp = OTPAsync(self._auth) self._totp = TOTPAsync(self._auth) self._webauthn = WebAuthnAsync(self._auth) @@ -162,6 +164,10 @@ def saml(self) -> SAMLAsync: def sso(self) -> SSOAsync: return self._sso + @property + def app(self) -> AppAsync: + return self._app + @property def webauthn(self) -> WebAuthnAsync: return self._webauthn diff --git a/samples/app_oidc_mfa_sample_app.py b/samples/app_oidc_mfa_sample_app.py new file mode 100644 index 000000000..804247fea --- /dev/null +++ b/samples/app_oidc_mfa_sample_app.py @@ -0,0 +1,128 @@ +""" +Homegrown first factor + Descope for MFA-only, via a Federated OIDC App. + +The pattern: + 1. Your own backend authenticates the first factor however it always has (this sample + fakes it with a hardcoded check - swap in your real logic). + 2. On success, call app.start(app_id, return_url, login_hint=) and redirect the browser to the result. This sample assumes the app's + console-configured default flow has already been edited to be MFA-only (e.g. a + magic-link challenge) - if yours still runs a full sign-up-or-in flow, either edit it in + place via the console's Flow editor, or pass flow= here to override + it per-call instead. + 3. Descope's login page uses login_hint (forwarded as oidc_login_hint) to know who it's + validating, runs just that flow's challenge, and redirects back with a code. + 4. Exchange the code for tokens. These are raw OIDC tokens (access_token/id_token/...), + not this SDK's usual sessionJwt/refreshJwt - decide what your app does with them (e.g. + treat a successful exchange as proof MFA passed, and mint/extend your own app's session + accordingly). + +Run: + PROJECT_ID=... APP_ID=... CLIENT_SECRET=... python app_oidc_mfa_sample_app.py +Then open http://127.0.0.1:5000/ in a browser. + +CLIENT_SECRET is only needed if the app is a confidential OAuth client (check its OIDC +settings in the console - a public/unspecified client doesn't need it, PKCE alone covers it). +CALLBACK_URL defaults to http://127.0.0.1:5000/callback; override it if the app's console +config registers a different redirect URI. +""" + +import os + +from flask import Flask, redirect, request, session + +from descope import AuthException, DescopeClient + +PROJECT_ID = os.environ["PROJECT_ID"] +APP_ID = os.environ["APP_ID"] +CLIENT_SECRET = os.environ.get("CLIENT_SECRET") # only needed for a confidential client +# Must match a redirect URI registered on the app in the console (or the app's redirect_uri +# validation may reject it at exchange time even though the initial redirect still succeeds). +CALLBACK_URL = os.environ.get("CALLBACK_URL", "http://127.0.0.1:5000/callback") + +# Swap for a real user store - this sample fakes the "homegrown first factor" entirely. +FAKE_USER_DB = {"user@example.com": "password123"} + +APP = Flask(__name__) +APP.secret_key = os.urandom(32) # dev-only; use a stable, secret key in production + +descope_client = DescopeClient(project_id=PROJECT_ID) + + +@APP.route("/") +def index(): + return """ +

Step 1: Your homegrown login

+
+ + + +
+ """ + + +@APP.route("/login", methods=["POST"]) +def login(): + email = request.form.get("email", "") + password = request.form.get("password", "") + + # --- your real first-factor check goes here --- + if FAKE_USER_DB.get(email) != password: + return "Invalid credentials", 401 + # ------------------------------------------------ + + try: + resp = descope_client.app.start( + APP_ID, + CALLBACK_URL, + login_hint=email, + # No `flow` override here: the app's console-configured default flow already IS + # the MFA-only flow (edited in place, same flow ID). Pass flow= explicitly instead, if you'd rather override per-call than change the app's + # default. + ) + except AuthException as e: + return f"Failed to start MFA: {e}", 500 + + # Stash what exchange_token will need - this is a redirect, so it can't carry state itself + session["code_verifier"] = resp["code_verifier"] + session["expected_state"] = resp["state"] + session["email"] = email + + return redirect(resp["url"]) + + +@APP.route("/callback") +def callback(): + code = request.args.get("code") + state = request.args.get("state") + error = request.args.get("error") + + if error: + return f"MFA failed: {error} - {request.args.get('error_description', '')}", 401 + if not code or state != session.get("expected_state"): + return "Invalid or missing callback params", 400 + + try: + tokens = descope_client.app.exchange_token( + APP_ID, + code, + code_verifier=session.get("code_verifier"), + client_secret=CLIENT_SECRET, + redirect_uri=CALLBACK_URL, + ) + except AuthException as e: + return f"MFA exchange failed: {e}", 401 + + # tokens is the raw OIDC shape: access_token, id_token, refresh_token, expires_in, scope. + # Treating a successful exchange as "MFA passed" - wire this into your own session logic + # as needed. + return f""" +

MFA complete for {session.get('email')}

+

id_token (truncated): {tokens.get('id_token', '')[:40]}...

+

expires_in: {tokens.get('expires_in')}s

+ """ + + +if __name__ == "__main__": + APP.run(port=5000) diff --git a/samples/app_sample_app.py b/samples/app_sample_app.py new file mode 100644 index 000000000..574a4f460 --- /dev/null +++ b/samples/app_sample_app.py @@ -0,0 +1,35 @@ +import logging + +from descope import AuthException, DescopeClient + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + app_id = "" # The Federated (OIDC) App ID from the Descope console (app.descope.com/applications) + + try: + descope_client = DescopeClient(project_id=project_id) + + logging.info("Building the sign-in redirect URL for an OIDC Federated App...") + resp = descope_client.app.start(app_id, "https://my-app.com/callback") + logging.info(f"app response: {resp}") + + # Redirect the browser to resp["url"]. Persist resp["state"] and + # resp["code_verifier"] (e.g. in a server-side session) until the callback: + # + # code = ... # from the "code" query param on the callback request + # jwt_response = descope_client.app.exchange_token( + # app_id, code, code_verifier=resp["code_verifier"] + # ) + # (jwt_response is the raw OIDC token shape: access_token, id_token, ... - + # not this SDK's usual sessionJwt/refreshJwt shape. Add client_secret= if the app + # is a confidential OAuth client.) + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 000000000..516beb20e --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,211 @@ +import base64 +import hashlib +from urllib.parse import parse_qs, urlsplit + +import pytest + +from descope import AuthException +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT + +from . import common + + +class TestApp: + def test_validate_return_url(self): + from descope.authmethod.app import App + + App._validate_return_url("https://x.com/cb") + with pytest.raises(AuthException): + App._validate_return_url(None) + with pytest.raises(AuthException): + App._validate_return_url("") + + def test_generate_pkce_pair(self): + from descope.authmethod.app import App + + verifier, challenge = App._generate_pkce_pair() + assert 43 <= len(verifier) <= 128 + expected_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .decode("ascii") + .rstrip("=") + ) + assert challenge == expected_challenge + assert "=" not in challenge + + # each call generates a fresh, unique pair + verifier2, _ = App._generate_pkce_pair() + assert verifier != verifier2 + + def test_build_oidc_client_id_matches_live_console_value(self): + """Regression lock: reproduces a real console-issued clientId byte-for-byte + (verified live against project P3I9XNBUps4jHk4ybbaezDSu7mjH's "Generic OIDC + Application", app SA3I9XPZkJYkcCwN34D77fNpGL1D9).""" + from descope.authmethod.app import App + + assert App._build_oidc_client_id( + "P3I9XNBUps4jHk4ybbaezDSu7mjH", "SA3I9XPZkJYkcCwN34D77fNpGL1D9" + ) == "UDNJOVhOQlVwczRqSGs0eWJiYWV6RFN1N21qSDpTQTNJOVhQWmtKWWtjQ3dOMzRENzdmTnBHTDFEOSMj" + + def test_build_oidc_client_id_padding(self): + from descope.authmethod.app import App + + for project_id, app_id in [("P1", "app1"), ("P12", "app1"), ("P123", "app1")]: + raw = f"{project_id}:{app_id}" + client_id = App._build_oidc_client_id(project_id, app_id) + assert "=" not in client_id + decoded = base64.b64decode(client_id).decode("ascii") + assert decoded.rstrip("#") == raw + + def test_compose_oidc_authorize_url(self): + from descope.authmethod.app import App + + url = App._compose_oidc_authorize_url( + "http://x.com", + "P1", + "app1", + "https://cb.com", + "tenant1", + "user@d.com", + "openid", + "state1", + "chal1", + "custom-mfa-flow", + ) + assert url.startswith("http://x.com/oauth2/v1/P1/authorize?") + query = parse_qs(urlsplit(url).query) + assert query == { + "response_type": ["code"], + "client_id": [App._build_oidc_client_id("P1", "app1")], + "redirect_uri": ["https://cb.com"], + "scope": ["openid"], + "state": ["state1"], + "code_challenge": ["chal1"], + "code_challenge_method": ["S256"], + "tenant": ["tenant1"], + "login_hint": ["user@d.com"], + "flow": ["custom-mfa-flow"], + } + + # flow is opt-in - omitted entirely when not given + url_no_flow = App._compose_oidc_authorize_url( + "http://x.com", "P1", "app1", "https://cb.com", "", "", "openid", "state1", "chal1", "" + ) + assert "flow" not in parse_qs(urlsplit(url_no_flow).query) + + def test_compose_oidc_token_body(self): + from descope.authmethod.app import App + + client_id = App._build_oidc_client_id("P1", "app1") + assert App._compose_oidc_token_body("P1", "app1", "code1", "", "", "") == { + "grant_type": "authorization_code", + "code": "code1", + "client_id": client_id, + } + assert App._compose_oidc_token_body( + "P1", "app1", "code1", "verifier1", "secret1", "https://cb.com" + ) == { + "grant_type": "authorization_code", + "code": "code1", + "client_id": client_id, + "code_verifier": "verifier1", + "client_secret": "secret1", + "redirect_uri": "https://cb.com", + } + + async def test_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors - app_id + with pytest.raises(AuthException): + await client.invoke(client.app.start("", "https://cb.com")) + with pytest.raises(AuthException): + await client.invoke(client.app.start(None, "https://cb.com")) + + # Validation errors - return_url is required + with pytest.raises(AuthException): + await client.invoke(client.app.start("app1", None)) + with pytest.raises(AuthException): + await client.invoke(client.app.start("app1", "")) + + # No network call is made - start() builds the URL and PKCE pair locally + result = await client.invoke(client.app.start("app1", "https://cb.com")) + assert set(result.keys()) == {"url", "state", "code_verifier"} + assert result["url"].startswith(f"{common.DEFAULT_BASE_URL}/oauth2/v1/{PROJECT_ID}/authorize?") + query = parse_qs(urlsplit(result["url"]).query) + assert query["response_type"] == ["code"] + from descope.authmethod.app import App + + assert query["client_id"] == [App._build_oidc_client_id(PROJECT_ID, "app1")] + assert query["redirect_uri"] == ["https://cb.com"] + assert query["scope"] == ["openid"] + assert query["state"] == [result["state"]] + assert query["code_challenge_method"] == ["S256"] + assert "code_challenge" in query + + # caller-supplied state is honored instead of a generated one + result = await client.invoke(client.app.start("app1", "https://cb.com", state="my-state")) + assert result["state"] == "my-state" + assert parse_qs(urlsplit(result["url"]).query)["state"] == ["my-state"] + + # homegrown-first-factor + Descope-for-MFA-only pattern: flow + login_hint together + result = await client.invoke( + client.app.start( + "app1", + "https://cb.com", + login_hint="user@d.com", + flow="custom-mfa-flow", + ) + ) + query = parse_qs(urlsplit(result["url"]).query) + assert query["flow"] == ["custom-mfa-flow"] + assert query["login_hint"] == ["user@d.com"] + + async def test_exchange_token(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.app.exchange_token("", "code1")) + with pytest.raises(AuthException): + await client.invoke(client.app.exchange_token("app1", "")) + with pytest.raises(AuthException): + await client.invoke(client.app.exchange_token("app1", None)) + + # HTTP error + with client.mock_post(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke(client.app.exchange_token("app1", "code1")) + + # Success - note the raw OAuth2/OIDC response shape, not sessionJwt/refreshJwt + oidc_tokens = { + "access_token": "at1", + "token_type": "Bearer", + "refresh_token": "rt1", + "id_token": "idt1", + "expires_in": 3600, + "scope": "openid", + } + with client.mock_post(make_response(oidc_tokens)) as mock_post: + result = await client.invoke( + client.app.exchange_token( + "app1", "code1", code_verifier="verifier1", redirect_uri="https://cb.com" + ) + ) + assert result == oidc_tokens + from descope.authmethod.app import App + + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}/oauth2/v1/{PROJECT_ID}/token", + data={ + "grant_type": "authorization_code", + "code": "code1", + "client_id": App._build_oidc_client_id(PROJECT_ID, "app1"), + "code_verifier": "verifier1", + "redirect_uri": "https://cb.com", + }, + follow_redirects=False, + ) From 341a2d84dcd2c4d1d2f9bb0e846a0b13412aa631 Mon Sep 17 00:00:00 2001 From: Mrunank Pawar <65391854+mrunankpawar@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:51:23 -0700 Subject: [PATCH 2/7] Potential fix for pull request finding 'CodeQL / Reflected server-side cross-site scripting' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- samples/app_oidc_mfa_sample_app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/samples/app_oidc_mfa_sample_app.py b/samples/app_oidc_mfa_sample_app.py index 804247fea..3b807502b 100644 --- a/samples/app_oidc_mfa_sample_app.py +++ b/samples/app_oidc_mfa_sample_app.py @@ -29,7 +29,7 @@ import os -from flask import Flask, redirect, request, session +from flask import Flask, escape, redirect, request, session from descope import AuthException, DescopeClient @@ -99,7 +99,9 @@ def callback(): error = request.args.get("error") if error: - return f"MFA failed: {error} - {request.args.get('error_description', '')}", 401 + safe_error = escape(error) + safe_error_description = escape(request.args.get("error_description", "")) + return f"MFA failed: {safe_error} - {safe_error_description}", 401 if not code or state != session.get("expected_state"): return "Invalid or missing callback params", 400 From a1f3d76db27c46b7b90dacdd4a15c1323f234218 Mon Sep 17 00:00:00 2001 From: Mrunank Pawar <65391854+mrunankpawar@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:51:34 -0700 Subject: [PATCH 3/7] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- samples/app_oidc_mfa_sample_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/app_oidc_mfa_sample_app.py b/samples/app_oidc_mfa_sample_app.py index 3b807502b..d91dd2286 100644 --- a/samples/app_oidc_mfa_sample_app.py +++ b/samples/app_oidc_mfa_sample_app.py @@ -82,7 +82,7 @@ def login(): # default. ) except AuthException as e: - return f"Failed to start MFA: {e}", 500 + return "Failed to start MFA", 500 # Stash what exchange_token will need - this is a redirect, so it can't carry state itself session["code_verifier"] = resp["code_verifier"] From c592d766f1dbbaa8ec9564c87b75349dd3cf48c9 Mon Sep 17 00:00:00 2001 From: Mrunank Pawar <65391854+mrunankpawar@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:51:42 -0700 Subject: [PATCH 4/7] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- samples/app_oidc_mfa_sample_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/app_oidc_mfa_sample_app.py b/samples/app_oidc_mfa_sample_app.py index d91dd2286..77577f9bd 100644 --- a/samples/app_oidc_mfa_sample_app.py +++ b/samples/app_oidc_mfa_sample_app.py @@ -113,8 +113,8 @@ def callback(): client_secret=CLIENT_SECRET, redirect_uri=CALLBACK_URL, ) - except AuthException as e: - return f"MFA exchange failed: {e}", 401 + except AuthException: + return "MFA exchange failed", 401 # tokens is the raw OIDC shape: access_token, id_token, refresh_token, expires_in, scope. # Treating a successful exchange as "MFA passed" - wire this into your own session logic From 41bde42c9fe39b6e9b5e744b20ff2ea5477ad5e2 Mon Sep 17 00:00:00 2001 From: Mrunank Pawar <65391854+mrunankpawar@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:59:51 -0700 Subject: [PATCH 5/7] Update samples/app_oidc_mfa_sample_app.py Co-authored-by: shuni-bot[bot] <251468265+shuni-bot[bot]@users.noreply.github.com> --- samples/app_oidc_mfa_sample_app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/samples/app_oidc_mfa_sample_app.py b/samples/app_oidc_mfa_sample_app.py index 77577f9bd..bcaf8b825 100644 --- a/samples/app_oidc_mfa_sample_app.py +++ b/samples/app_oidc_mfa_sample_app.py @@ -29,7 +29,8 @@ import os -from flask import Flask, escape, redirect, request, session +from flask import Flask, redirect, request, session +from markupsafe import escape from descope import AuthException, DescopeClient From 9d4604335fa7ee1b6efec6954c7201ef2c2975a1 Mon Sep 17 00:00:00 2001 From: Mrunank Pawar Date: Mon, 24 Aug 2026 17:18:07 +0000 Subject: [PATCH 6/7] fix(samples): remove unused exception binding in app_oidc_mfa_sample_app Ruff F841 - the AuthException wasn't referenced in the except block. --- samples/app_oidc_mfa_sample_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/app_oidc_mfa_sample_app.py b/samples/app_oidc_mfa_sample_app.py index bcaf8b825..854587752 100644 --- a/samples/app_oidc_mfa_sample_app.py +++ b/samples/app_oidc_mfa_sample_app.py @@ -82,7 +82,7 @@ def login(): # ID> explicitly instead, if you'd rather override per-call than change the app's # default. ) - except AuthException as e: + except AuthException: return "Failed to start MFA", 500 # Stash what exchange_token will need - this is a redirect, so it can't carry state itself From e78d3a6ba9c4d3cab4c0a7f7847b3b7ece12b68d Mon Sep 17 00:00:00 2001 From: Mrunank Pawar Date: Mon, 24 Aug 2026 17:18:09 +0000 Subject: [PATCH 7/7] fix(app): route exchange_token through shared retry/rate-limit handling exchange_token bypassed the SDK's normal HTTP layer entirely for its one-shot httpx call, so a transient 429/5xx from Descope's own token endpoint surfaced as a generic AuthException instead of being retried or raised as RateLimitException like every other call in the SDK. Keep the form-encoded body/headers bypass (justified - no bearer token), but reuse HTTPClient._execute_with_retry (sync) / _async_execute_with_retry (async) and _raise_from_response so retry/rate-limit behavior stays consistent with the rest of the SDK. --- descope/authmethod/app.py | 28 +++++++++++++++------------- descope/authmethod/app_async.py | 15 ++++++++------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/descope/authmethod/app.py b/descope/authmethod/app.py index eb9a90592..1020a538e 100644 --- a/descope/authmethod/app.py +++ b/descope/authmethod/app.py @@ -6,7 +6,7 @@ from descope._authmethod_base import AuthMethodBase from descope.authmethod._app_base import AppBase -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, ERROR_TYPE_SERVER_ERROR, AuthException +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class App(AppBase, AuthMethodBase): @@ -80,10 +80,11 @@ def exchange_token( CONFIRMED LIVE end-to-end: a real login through the URL from ``start``, followed by this call with the resulting code, code_verifier, and the app's client_secret, returned real access/refresh/ID tokens (see ``AppBase`` for the details). This - bypasses the SDK's normal HTTP layer deliberately - the token endpoint is a standard - OAuth2 endpoint (form-encoded body, client credentials in the body, no Descope bearer - header), confirmed working via ``client_secret_post`` (secret in the body, per the - project's discovery document). + deliberately bypasses the SDK's default headers/JSON body - the token endpoint is a + standard OAuth2 endpoint (form-encoded body, client credentials in the body, no + Descope bearer header), confirmed working via ``client_secret_post`` (secret in the + body, per the project's discovery document). Retries and rate-limit handling still go + through the shared HTTP client. Args: app_id (str): The Federated App ID passed to ``start`` @@ -108,13 +109,14 @@ def exchange_token( client_secret if client_secret else "", redirect_uri if redirect_uri else "", ) - response = httpx.post( - f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token", - data=body, - follow_redirects=False, - verify=self._http.client_verify, - timeout=self._http.timeout_seconds, + response = self._http._execute_with_retry( + lambda: httpx.post( + f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token", + data=body, + follow_redirects=False, + verify=self._http.client_verify, + timeout=self._http.timeout_seconds, + ) ) - if response.status_code >= 400: - raise AuthException(response.status_code, ERROR_TYPE_SERVER_ERROR, response.text) + self._http._raise_from_response(response) return response.json() diff --git a/descope/authmethod/app_async.py b/descope/authmethod/app_async.py index 127c09861..478b7295b 100644 --- a/descope/authmethod/app_async.py +++ b/descope/authmethod/app_async.py @@ -4,7 +4,7 @@ from descope._authmethod_base import AsyncAuthMethodBase from descope.authmethod._app_base import AppBase -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, ERROR_TYPE_SERVER_ERROR, AuthException +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class AppAsync(AppBase, AsyncAuthMethodBase): @@ -66,11 +66,12 @@ async def exchange_token( client_secret if client_secret else "", redirect_uri if redirect_uri else "", ) - response = await self._http._async_client.post( - f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token", - data=body, - follow_redirects=False, + response = await self._http._async_execute_with_retry( + lambda: self._http._async_client.post( + f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token", + data=body, + follow_redirects=False, + ) ) - if response.status_code >= 400: - raise AuthException(response.status_code, ERROR_TYPE_SERVER_ERROR, response.text) + self._http._raise_from_response(response) return response.json()