Skip to content
Open
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 160 additions & 0 deletions descope/authmethod/_app_base.py
Original file line number Diff line number Diff line change
@@ -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
122 changes: 122 additions & 0 deletions descope/authmethod/app.py
Original file line number Diff line number Diff line change
@@ -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()
77 changes: 77 additions & 0 deletions descope/authmethod/app_async.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading