|
| 1 | +# This is not part of the public API but a code helper |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import hashlib |
| 5 | +import secrets |
| 6 | +from base64 import b64encode as base64encode |
| 7 | +from base64 import urlsafe_b64encode |
| 8 | +from typing import Dict, Optional |
| 9 | +from urllib.parse import urlencode |
| 10 | + |
| 11 | +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException |
| 12 | + |
| 13 | + |
| 14 | +class AppBase: |
| 15 | + """Shared, I/O-free base for the Federated App auth-method classes. |
| 16 | +
|
| 17 | + Holds only static validation guards and URL/param composers — no network I/O, no |
| 18 | + ``__init__``. The two concrete subclasses add the network layer (used only by |
| 19 | + ``exchange_token``): |
| 20 | +
|
| 21 | + - ``App(AppBase, AuthMethodBase)`` — sync, uses ``self._http`` (``HTTPClient``) |
| 22 | + - ``AppAsync(AppBase, AsyncAuthMethodBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) |
| 23 | +
|
| 24 | + A "Federated App" is configured in the Descope Console and represents a |
| 25 | + homegrown/third-party application that delegates its sign-in flow to Descope, with |
| 26 | + Descope acting as the IDP - a real OAuth2 authorize/token pair, confirmed live against a |
| 27 | + real project's ".well-known/openid-configuration": ``/oauth2/v1/{project_id}/authorize`` |
| 28 | + and ``/oauth2/v1/{project_id}/token``, with ``client_id`` set to the app's dedicated OIDC |
| 29 | + client ID: ``base64(f"{project_id}:{app_id}")``, padded with trailing ``#``/``##`` so the |
| 30 | + encoding needs no ``=`` (verified byte-for-byte against a live console-issued |
| 31 | + ``clientId``; see ``_build_oidc_client_id``). |
| 32 | +
|
| 33 | + ``start`` never calls the network: the authorize endpoint 303-redirects rather than |
| 34 | + returning JSON, so this just builds that URL (and a fresh PKCE pair) locally and hands it |
| 35 | + back for you to redirect the browser to. |
| 36 | +
|
| 37 | + ``flow`` picks which Descope Flow the login page runs, overriding the app's console |
| 38 | + default (confirmed live: passing an explicit ``flow`` value changes the login page's own |
| 39 | + ``flow`` query param accordingly). This is how to build a "homegrown first factor, |
| 40 | + Descope for MFA only" integration: your own backend handles the first factor, then calls |
| 41 | + ``start`` with ``flow`` set to whatever your own MFA-only flow's ID is (or leave it unset |
| 42 | + if the app's console-configured default flow is already that MFA flow), and |
| 43 | + ``login_hint`` set to the already-identified user - confirmed live to arrive at the login |
| 44 | + page as ``oidc_login_hint``. There is no session-based shortcut available here: OIDC's |
| 45 | + other step-up mechanism (an ``su`` claim carried on Descope's own short-lived "DS" |
| 46 | + session cookie, forwarded via ``oidc-su-session``) requires the browser to already hold a |
| 47 | + Descope-issued session from some prior Descope-native auth - it doesn't apply when the |
| 48 | + first factor never touches Descope at all, which is exactly this case. |
| 49 | +
|
| 50 | + CONFIRMED LIVE, full round trip, against a real confidential-client test app |
| 51 | + (project P3I9XNBUps4jHk4ybbaezDSu7mjH, app SA3I9XPZkJYkcCwN34D77fNpGL1D9): ``start``'s |
| 52 | + URL 303-redirected to Descope's hosted login page with the expected ``sso_app_id``; after |
| 53 | + completing that login (twice - once against the default flow, once against a |
| 54 | + console-edited magic-link MFA flow) and pasting back the resulting ``code``, |
| 55 | + ``exchange_token`` (with both ``code_verifier`` and ``client_secret`` supplied) returned |
| 56 | + real ``access_token``/``refresh_token``/``id_token`` JWTs with `expires_in: 600`. So for a |
| 57 | + confidential client, PKCE + client_secret together are accepted (the client_secret is |
| 58 | + what's actually required for this client type; PKCE was extra and harmless). Passing a |
| 59 | + ``client_secret`` on a public client, or omitting it on a confidential one, is not yet |
| 60 | + tested. Also not yet tested: redirect_uri validation (an unregistered redirect_uri did |
| 61 | + not block the initial authorize redirect in testing, which suggests it's checked later, |
| 62 | + right before the post-login redirect back - not confirmed). |
| 63 | +
|
| 64 | + One thing is still defaulted rather than known per-app up front: whether a *given* app |
| 65 | + requires PKCE (public client) vs a client secret (confidential) vs either (unspecified) - |
| 66 | + ``app_id`` alone doesn't say which, so ``start`` always generates a PKCE pair regardless |
| 67 | + (confirmed harmless above for a confidential app); pass the resulting ``code_verifier`` |
| 68 | + through to ``exchange_token`` either way, and add ``client_secret`` if the app turns out |
| 69 | + to be confidential. The discovery doc lists both ``client_secret_basic`` and |
| 70 | + ``client_secret_post`` as supported; this SDK uses the latter (secret in the POST body, |
| 71 | + not a Basic auth header) - the live test above confirms that choice works. |
| 72 | + """ |
| 73 | + |
| 74 | + @staticmethod |
| 75 | + def _validate_app_id(app_id: Optional[str]) -> None: |
| 76 | + if not app_id: |
| 77 | + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "App ID cannot be empty") |
| 78 | + |
| 79 | + @staticmethod |
| 80 | + def _validate_return_url(return_url: Optional[str]) -> None: |
| 81 | + if not return_url: |
| 82 | + raise AuthException( |
| 83 | + 400, |
| 84 | + ERROR_TYPE_INVALID_ARGUMENT, |
| 85 | + "return_url is required (it must match a redirect URI registered on the app " |
| 86 | + "in the Descope Console)", |
| 87 | + ) |
| 88 | + |
| 89 | + @staticmethod |
| 90 | + def _generate_random_token(nbytes: int = 32) -> str: |
| 91 | + return secrets.token_urlsafe(nbytes) |
| 92 | + |
| 93 | + @staticmethod |
| 94 | + def _generate_pkce_pair() -> tuple: |
| 95 | + """Returns (code_verifier, code_challenge) - RFC 7636, S256 method.""" |
| 96 | + code_verifier = secrets.token_urlsafe(64)[:128] |
| 97 | + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() |
| 98 | + code_challenge = urlsafe_b64encode(digest).decode("ascii").rstrip("=") |
| 99 | + return code_verifier, code_challenge |
| 100 | + |
| 101 | + @staticmethod |
| 102 | + def _build_oidc_client_id(project_id: str, app_id: str) -> str: |
| 103 | + """Replicates the backend's ``BuildApplicationClientID`` - verified to reproduce a |
| 104 | + real console-issued ``clientId`` byte-for-byte. Standard (not URL-safe) base64, |
| 105 | + padded with ``#``/``##`` before encoding so the output needs no ``=``.""" |
| 106 | + raw = f"{project_id}:{app_id}" |
| 107 | + pad = {1: "##", 2: "#"}.get(len(raw) % 3, "") |
| 108 | + return base64encode((raw + pad).encode("ascii")).decode("ascii") |
| 109 | + |
| 110 | + @staticmethod |
| 111 | + def _compose_oidc_authorize_url( |
| 112 | + base_url: str, |
| 113 | + project_id: str, |
| 114 | + app_id: str, |
| 115 | + return_url: str, |
| 116 | + tenant: str, |
| 117 | + login_hint: str, |
| 118 | + scope: str, |
| 119 | + state: str, |
| 120 | + code_challenge: str, |
| 121 | + flow: str, |
| 122 | + ) -> str: |
| 123 | + params: Dict[str, str] = { |
| 124 | + "response_type": "code", |
| 125 | + "client_id": AppBase._build_oidc_client_id(project_id, app_id), |
| 126 | + "redirect_uri": return_url, |
| 127 | + "scope": scope, |
| 128 | + "state": state, |
| 129 | + "code_challenge": code_challenge, |
| 130 | + "code_challenge_method": "S256", |
| 131 | + } |
| 132 | + if tenant: |
| 133 | + params["tenant"] = tenant |
| 134 | + if login_hint: |
| 135 | + params["login_hint"] = login_hint |
| 136 | + if flow: |
| 137 | + params["flow"] = flow |
| 138 | + return f"{base_url}/oauth2/v1/{project_id}/authorize?{urlencode(params)}" |
| 139 | + |
| 140 | + @staticmethod |
| 141 | + def _compose_oidc_token_body( |
| 142 | + project_id: str, |
| 143 | + app_id: str, |
| 144 | + code: str, |
| 145 | + code_verifier: str, |
| 146 | + client_secret: str, |
| 147 | + redirect_uri: str, |
| 148 | + ) -> Dict[str, str]: |
| 149 | + body: Dict[str, str] = { |
| 150 | + "grant_type": "authorization_code", |
| 151 | + "code": code, |
| 152 | + "client_id": AppBase._build_oidc_client_id(project_id, app_id), |
| 153 | + } |
| 154 | + if code_verifier: |
| 155 | + body["code_verifier"] = code_verifier |
| 156 | + if client_secret: |
| 157 | + body["client_secret"] = client_secret |
| 158 | + if redirect_uri: |
| 159 | + body["redirect_uri"] = redirect_uri |
| 160 | + return body |
0 commit comments