diff --git a/README.md b/README.md index 1e949373c..e9a75ca47 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..1020a538e --- /dev/null +++ b/descope/authmethod/app.py @@ -0,0 +1,122 @@ +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, 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 + 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`` + 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 = 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, + ) + ) + self._http._raise_from_response(response) + return response.json() diff --git a/descope/authmethod/app_async.py b/descope/authmethod/app_async.py new file mode 100644 index 000000000..478b7295b --- /dev/null +++ b/descope/authmethod/app_async.py @@ -0,0 +1,77 @@ +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, 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_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, + ) + ) + self._http._raise_from_response(response) + 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 5c934b232..23181ad0d 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -9,6 +9,7 @@ from descope._client_base import DescopeClientBase from descope._http_client_base import ThreadLocalLastResponseStore 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 @@ -77,6 +78,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) @@ -169,6 +171,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 1763f61dc..a29ba44e5 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -10,6 +10,7 @@ from descope._client_base import DescopeClientBase from descope._http_client_base import ContextVarLastResponseStore 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 @@ -125,6 +126,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) @@ -166,6 +168,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..854587752 --- /dev/null +++ b/samples/app_oidc_mfa_sample_app.py @@ -0,0 +1,131 @@ +""" +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 markupsafe import escape + +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: + 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"] + 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: + 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 + + 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: + 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 + # 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, + )