Skip to content

Commit 083818e

Browse files
mrunankpawarclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 65a2791 commit 083818e

10 files changed

Lines changed: 769 additions & 0 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,31 @@ The session and refresh JWTs should be returned to the caller, and passed with e
256256

257257
Note: the descope_client.saml.start(..) and descope_client.saml.exchange_token(..) functions are DEPRECATED, use the above sso functions instead
258258

259+
### App (Federated Apps)
260+
261+
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.)
262+
263+
```python
264+
resp = descope_client.app.start(
265+
app_id="my-federated-app-id", # The Federated App ID from the Descope console
266+
return_url="https://my-app.com/callback", # must match a redirect URI registered on the app
267+
login_hint="user@example.com", # Optional hint about the user's login identifier
268+
)
269+
# Persist resp["state"] and resp["code_verifier"] (e.g. server-side session) until the callback,
270+
# then redirect the browser to resp["url"]. This call makes no network request - it just builds
271+
# the URL (and a fresh PKCE pair) locally, since the authorize endpoint 303-redirects rather
272+
# than returning JSON.
273+
274+
# On the callback (code arrives as a query param):
275+
jwt_response = descope_client.app.exchange_token(app_id, code, code_verifier=resp["code_verifier"])
276+
# jwt_response is the raw OAuth2/OIDC token shape (access_token, id_token, refresh_token, ...) -
277+
# not this SDK's usual sessionJwt/refreshJwt shape.
278+
```
279+
280+
`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.
281+
282+
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.
283+
259284
### TOTP Authentication
260285

261286
The user can authenticate using an authenticator app, such as Google Authenticator.

descope/authmethod/_app_base.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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

descope/authmethod/app.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
5+
import httpx
6+
7+
from descope._authmethod_base import AuthMethodBase
8+
from descope.authmethod._app_base import AppBase
9+
from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, ERROR_TYPE_SERVER_ERROR, AuthException
10+
11+
12+
class App(AppBase, AuthMethodBase):
13+
def start(
14+
self,
15+
app_id: str,
16+
return_url: str,
17+
tenant: Optional[str] = None,
18+
login_hint: Optional[str] = None,
19+
scope: Optional[str] = None,
20+
state: Optional[str] = None,
21+
flow: Optional[str] = None,
22+
) -> dict:
23+
"""
24+
Build the sign-in redirect URL for an OIDC Federated App.
25+
26+
This makes no network call: the authorize endpoint 303-redirects rather than
27+
returning JSON, so this just builds the URL (and a fresh PKCE pair) locally. See
28+
``AppBase`` for the full explanation and the live round-trip confirmation.
29+
30+
Args:
31+
app_id (str): The Federated App ID (as configured in the Descope Console)
32+
return_url (str): Must match a redirect URI registered on the app in the console.
33+
tenant (str, optional): Tenant ID or name, for apps scoped to a specific tenant
34+
login_hint (str, optional): Hint about the user's login identifier
35+
scope (str, optional): Defaults to "openid"
36+
state (str, optional): Defaults to a generated random value (returned back to you
37+
either way, so you can verify it on the callback)
38+
flow (str, optional): Which Descope Flow the login page runs, overriding the
39+
app's console default. Use this for a "homegrown first factor, Descope for
40+
MFA only" integration: pass your own MFA-only flow's ID along with
41+
``login_hint`` set to the already-identified user - confirmed live to reach
42+
the login page as ``oidc_login_hint``. Leave unset if the app's console
43+
default flow is already the MFA flow you want. See ``AppBase`` for why the
44+
session-cookie-based step-up shortcut doesn't apply when the first factor
45+
never touches Descope.
46+
47+
Return value (dict): ``{'url': ..., 'state': ..., 'code_verifier': ...}`` - hold onto
48+
``state`` and ``code_verifier`` and pass them to ``exchange_token``.
49+
"""
50+
self._validate_app_id(app_id)
51+
self._validate_return_url(return_url)
52+
53+
code_verifier, code_challenge = self._generate_pkce_pair()
54+
resolved_state = state if state else self._generate_random_token()
55+
url = self._compose_oidc_authorize_url(
56+
self._http.base_url,
57+
self._auth.project_id,
58+
app_id,
59+
return_url,
60+
tenant if tenant else "",
61+
login_hint if login_hint else "",
62+
scope if scope else "openid",
63+
resolved_state,
64+
code_challenge,
65+
flow if flow else "",
66+
)
67+
return {"url": url, "state": resolved_state, "code_verifier": code_verifier}
68+
69+
def exchange_token(
70+
self,
71+
app_id: str,
72+
code: str,
73+
code_verifier: Optional[str] = None,
74+
client_secret: Optional[str] = None,
75+
redirect_uri: Optional[str] = None,
76+
) -> dict:
77+
"""
78+
Exchange a Federated App authorization code for tokens.
79+
80+
CONFIRMED LIVE end-to-end: a real login through the URL from ``start``, followed by
81+
this call with the resulting code, code_verifier, and the app's client_secret,
82+
returned real access/refresh/ID tokens (see ``AppBase`` for the details). This
83+
bypasses the SDK's normal HTTP layer deliberately - the token endpoint is a standard
84+
OAuth2 endpoint (form-encoded body, client credentials in the body, no Descope bearer
85+
header), confirmed working via ``client_secret_post`` (secret in the body, per the
86+
project's discovery document).
87+
88+
Args:
89+
app_id (str): The Federated App ID passed to ``start``
90+
code (str): The authorization code from the redirect callback
91+
code_verifier (str, optional): The value ``start`` returned - pass it even for a
92+
confidential app (harmless extra; confirmed live alongside client_secret)
93+
client_secret (str, optional): Required if the app is a confidential client
94+
redirect_uri (str, optional): Must match the return_url passed to ``start``
95+
96+
Returns dict in the raw OAuth2/OIDC token shape (access_token, token_type,
97+
refresh_token, id_token, expires_in, scope) - not this SDK's usual session shape.
98+
"""
99+
self._validate_app_id(app_id)
100+
if not code:
101+
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "code cannot be empty")
102+
103+
body = self._compose_oidc_token_body(
104+
self._auth.project_id,
105+
app_id,
106+
code,
107+
code_verifier if code_verifier else "",
108+
client_secret if client_secret else "",
109+
redirect_uri if redirect_uri else "",
110+
)
111+
response = httpx.post(
112+
f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token",
113+
data=body,
114+
follow_redirects=False,
115+
verify=self._http.client_verify,
116+
timeout=self._http.timeout_seconds,
117+
)
118+
if response.status_code >= 400:
119+
raise AuthException(response.status_code, ERROR_TYPE_SERVER_ERROR, response.text)
120+
return response.json()

descope/authmethod/app_async.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
5+
from descope._authmethod_base import AsyncAuthMethodBase
6+
from descope.authmethod._app_base import AppBase
7+
from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, ERROR_TYPE_SERVER_ERROR, AuthException
8+
9+
10+
class AppAsync(AppBase, AsyncAuthMethodBase):
11+
"""Async Federated App (OIDC only - see AppBase) auth-method. ``start`` is I/O-free but
12+
stays ``async def`` for a consistent call shape; ``exchange_token`` does a real
13+
coroutine-based network call."""
14+
15+
async def start(
16+
self,
17+
app_id: str,
18+
return_url: str,
19+
tenant: Optional[str] = None,
20+
login_hint: Optional[str] = None,
21+
scope: Optional[str] = None,
22+
state: Optional[str] = None,
23+
flow: Optional[str] = None,
24+
) -> dict:
25+
"""Build the sign-in redirect URL for an OIDC Federated App; see ``App.start`` (the
26+
sync equivalent) for the full explanation."""
27+
self._validate_app_id(app_id)
28+
self._validate_return_url(return_url)
29+
30+
code_verifier, code_challenge = self._generate_pkce_pair()
31+
resolved_state = state if state else self._generate_random_token()
32+
url = self._compose_oidc_authorize_url(
33+
self._http.base_url,
34+
self._auth.project_id,
35+
app_id,
36+
return_url,
37+
tenant if tenant else "",
38+
login_hint if login_hint else "",
39+
scope if scope else "openid",
40+
resolved_state,
41+
code_challenge,
42+
flow if flow else "",
43+
)
44+
return {"url": url, "state": resolved_state, "code_verifier": code_verifier}
45+
46+
async def exchange_token(
47+
self,
48+
app_id: str,
49+
code: str,
50+
code_verifier: Optional[str] = None,
51+
client_secret: Optional[str] = None,
52+
redirect_uri: Optional[str] = None,
53+
) -> dict:
54+
"""Exchange a Federated App authorization code for tokens; see
55+
``App.exchange_token`` (the sync equivalent) for the full explanation - confirmed
56+
with a live end-to-end test."""
57+
self._validate_app_id(app_id)
58+
if not code:
59+
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "code cannot be empty")
60+
61+
body = self._compose_oidc_token_body(
62+
self._auth.project_id,
63+
app_id,
64+
code,
65+
code_verifier if code_verifier else "",
66+
client_secret if client_secret else "",
67+
redirect_uri if redirect_uri else "",
68+
)
69+
response = await self._http._async_client.post(
70+
f"{self._http.base_url}/oauth2/v1/{self._auth.project_id}/token",
71+
data=body,
72+
follow_redirects=False,
73+
)
74+
if response.status_code >= 400:
75+
raise AuthException(response.status_code, ERROR_TYPE_SERVER_ERROR, response.text)
76+
return response.json()

descope/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class EndpointsV1:
7474
auth_sso_start_path = "/v1/auth/sso/authorize"
7575
sso_exchange_token_path = "/v1/auth/sso/exchange"
7676

77+
# app (federated apps - oidc only; see AppBase for why saml/wsfed aren't supported)
78+
7779
# totp
7880
sign_up_auth_totp_path = "/v1/auth/totp/signup"
7981
verify_totp_path = "/v1/auth/totp/verify"

0 commit comments

Comments
 (0)