-
Notifications
You must be signed in to change notification settings - Fork 10
feat(app): add OIDC federated app sign-in (start/exchange_token) #1676
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mrunankpawar
wants to merge
8
commits into
main
Choose a base branch
from
feat/oidc-federated-app-signin
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
083818e
feat(app): add OIDC federated app sign-in (start/exchange_token)
mrunankpawar 341a2d8
Potential fix for pull request finding 'CodeQL / Reflected server-sid…
mrunankpawar a1f3d76
Potential fix for pull request finding 'CodeQL / Information exposure…
mrunankpawar c592d76
Potential fix for pull request finding 'CodeQL / Information exposure…
mrunankpawar 8497413
Merge branch 'main' into feat/oidc-federated-app-signin
mrunankpawar 41bde42
Update samples/app_oidc_mfa_sample_app.py
mrunankpawar 9d46043
fix(samples): remove unused exception binding in app_oidc_mfa_sample_app
mrunankpawar e78d3a6
fix(app): route exchange_token through shared retry/rate-limit handling
mrunankpawar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.