|
12 | 12 | from auth0_api_python.errors import ( |
13 | 13 | ApiError, |
14 | 14 | GetAccessTokenForConnectionError, |
| 15 | + GetTokenByExchangeProfileError, |
15 | 16 | InvalidAuthSchemeError, |
16 | 17 | InvalidDpopProofError, |
17 | 18 | MissingAuthorizationError, |
@@ -1901,3 +1902,318 @@ async def test_get_access_token_for_connection_expires_in_not_integer(httpx_mock |
1901 | 1902 | assert err.value.code == "invalid_response" |
1902 | 1903 | assert "expires_in" in str(err.value).lower() |
1903 | 1904 | assert err.value.status_code == 502 |
| 1905 | + |
| 1906 | + |
| 1907 | +# ===== Custom Token Exchange Tests ===== |
| 1908 | + |
| 1909 | + |
| 1910 | +@pytest.mark.asyncio |
| 1911 | +async def test_get_token_by_exchange_profile_success(httpx_mock: HTTPXMock): |
| 1912 | + """Test successful token exchange via profile.""" |
| 1913 | + httpx_mock.add_response( |
| 1914 | + method="GET", |
| 1915 | + url="https://auth0.local/.well-known/openid-configuration", |
| 1916 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 1917 | + ) |
| 1918 | + httpx_mock.add_response( |
| 1919 | + method="POST", |
| 1920 | + url="https://auth0.local/oauth/token", |
| 1921 | + json={ |
| 1922 | + "access_token": "exchanged_token", |
| 1923 | + "expires_in": 3600, |
| 1924 | + "scope": "openid profile", |
| 1925 | + "id_token": "id_token_value", |
| 1926 | + "refresh_token": "refresh_token_value", |
| 1927 | + "token_type": "Bearer", |
| 1928 | + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token" |
| 1929 | + } |
| 1930 | + ) |
| 1931 | + |
| 1932 | + api_client = ApiClient(ApiClientOptions( |
| 1933 | + domain="auth0.local", |
| 1934 | + audience="my-audience", |
| 1935 | + client_id="cid", |
| 1936 | + client_secret="csecret" |
| 1937 | + )) |
| 1938 | + |
| 1939 | + result = await api_client.get_token_by_exchange_profile( |
| 1940 | + subject_token="custom-token-123", |
| 1941 | + subject_token_type="urn:example:custom-token", |
| 1942 | + audience="https://api.example.com", |
| 1943 | + scope="openid profile" |
| 1944 | + ) |
| 1945 | + |
| 1946 | + assert result["access_token"] == "exchanged_token" |
| 1947 | + assert result["scope"] == "openid profile" |
| 1948 | + assert result["id_token"] == "id_token_value" |
| 1949 | + assert result["refresh_token"] == "refresh_token_value" |
| 1950 | + assert result["token_type"] == "Bearer" |
| 1951 | + assert result["issued_token_type"] == "urn:ietf:params:oauth:token-type:access_token" |
| 1952 | + assert isinstance(result["expires_at"], int) |
| 1953 | + |
| 1954 | + # Verify request parameters |
| 1955 | + request = httpx_mock.get_requests()[-1] |
| 1956 | + form_data = urllib.parse.parse_qs(request.content.decode()) |
| 1957 | + assert form_data["grant_type"] == ["urn:ietf:params:oauth:grant-type:token-exchange"] |
| 1958 | + assert form_data["subject_token"] == ["custom-token-123"] |
| 1959 | + assert form_data["subject_token_type"] == ["urn:example:custom-token"] |
| 1960 | + assert form_data["audience"] == ["https://api.example.com"] |
| 1961 | + |
| 1962 | + |
| 1963 | +@pytest.mark.parametrize("missing_field,field_name", [ |
| 1964 | + ("", "subject_token"), |
| 1965 | + (None, "subject_token"), |
| 1966 | +]) |
| 1967 | +@pytest.mark.asyncio |
| 1968 | +async def test_get_token_by_exchange_profile_missing_required(missing_field, field_name): |
| 1969 | + """Test that missing required parameters raise error.""" |
| 1970 | + api_client = ApiClient(ApiClientOptions( |
| 1971 | + domain="auth0.local", |
| 1972 | + audience="my-audience", |
| 1973 | + client_id="cid", |
| 1974 | + client_secret="csecret" |
| 1975 | + )) |
| 1976 | + |
| 1977 | + kwargs = { |
| 1978 | + "subject_token": "token" if field_name != "subject_token" else missing_field, |
| 1979 | + "subject_token_type": "urn:example:type" if field_name != "subject_token_type" else missing_field, |
| 1980 | + } |
| 1981 | + |
| 1982 | + with pytest.raises(MissingRequiredArgumentError): |
| 1983 | + await api_client.get_token_by_exchange_profile(**kwargs) |
| 1984 | + |
| 1985 | + |
| 1986 | +@pytest.mark.parametrize("invalid_token,expected_error,expected_msg", [ |
| 1987 | + (" token", GetTokenByExchangeProfileError, "leading or trailing whitespace"), |
| 1988 | + ("token ", GetTokenByExchangeProfileError, "leading or trailing whitespace"), |
| 1989 | + (" ", GetTokenByExchangeProfileError, "blank or whitespace"), |
| 1990 | + ("Bearer token123", GetTokenByExchangeProfileError, "'Bearer ' prefix"), |
| 1991 | + ("bearer token123", GetTokenByExchangeProfileError, "'Bearer ' prefix"), |
| 1992 | + ("BEARER token123", GetTokenByExchangeProfileError, "'Bearer ' prefix"), |
| 1993 | +]) |
| 1994 | +@pytest.mark.asyncio |
| 1995 | +async def test_get_token_by_exchange_profile_invalid_subject_token(invalid_token, expected_error, expected_msg): |
| 1996 | + """Test subject token validation.""" |
| 1997 | + api_client = ApiClient(ApiClientOptions( |
| 1998 | + domain="auth0.local", |
| 1999 | + audience="my-audience", |
| 2000 | + client_id="cid", |
| 2001 | + client_secret="csecret" |
| 2002 | + )) |
| 2003 | + |
| 2004 | + with pytest.raises(expected_error) as err: |
| 2005 | + await api_client.get_token_by_exchange_profile( |
| 2006 | + subject_token=invalid_token, |
| 2007 | + subject_token_type="urn:example:type" |
| 2008 | + ) |
| 2009 | + assert expected_msg.lower() in str(err.value).lower() |
| 2010 | + |
| 2011 | + |
| 2012 | +@pytest.mark.asyncio |
| 2013 | +async def test_get_token_by_exchange_profile_missing_client_credentials(): |
| 2014 | + """Test that missing client credentials raises error.""" |
| 2015 | + api_client = ApiClient(ApiClientOptions( |
| 2016 | + domain="auth0.local", |
| 2017 | + audience="my-audience" |
| 2018 | + )) |
| 2019 | + |
| 2020 | + with pytest.raises(GetTokenByExchangeProfileError) as err: |
| 2021 | + await api_client.get_token_by_exchange_profile( |
| 2022 | + subject_token="token", |
| 2023 | + subject_token_type="urn:example:type" |
| 2024 | + ) |
| 2025 | + assert "client credentials" in str(err.value).lower() |
| 2026 | + |
| 2027 | + |
| 2028 | +@pytest.mark.asyncio |
| 2029 | +async def test_get_token_by_exchange_profile_with_extra_params(httpx_mock: HTTPXMock): |
| 2030 | + """Test token exchange with extra parameters.""" |
| 2031 | + httpx_mock.add_response( |
| 2032 | + method="GET", |
| 2033 | + url="https://auth0.local/.well-known/openid-configuration", |
| 2034 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 2035 | + ) |
| 2036 | + httpx_mock.add_response( |
| 2037 | + method="POST", |
| 2038 | + url="https://auth0.local/oauth/token", |
| 2039 | + json={"access_token": "token", "expires_in": 3600} |
| 2040 | + ) |
| 2041 | + |
| 2042 | + api_client = ApiClient(ApiClientOptions( |
| 2043 | + domain="auth0.local", |
| 2044 | + audience="my-audience", |
| 2045 | + client_id="cid", |
| 2046 | + client_secret="csecret" |
| 2047 | + )) |
| 2048 | + |
| 2049 | + await api_client.get_token_by_exchange_profile( |
| 2050 | + subject_token="token", |
| 2051 | + subject_token_type="urn:example:type", |
| 2052 | + extra={ |
| 2053 | + "device_id": "dev123", |
| 2054 | + "roles": ["admin", "user"] |
| 2055 | + } |
| 2056 | + ) |
| 2057 | + |
| 2058 | + request = httpx_mock.get_requests()[-1] |
| 2059 | + form_data = urllib.parse.parse_qs(request.content.decode()) |
| 2060 | + assert form_data["device_id"] == ["dev123"] |
| 2061 | + assert form_data["roles"] == ["admin", "user"] |
| 2062 | + |
| 2063 | + |
| 2064 | +@pytest.mark.parametrize("denied_param", [ |
| 2065 | + "grant_type", "client_id", "client_secret", "subject_token", |
| 2066 | + "subject_token_type", "audience", "scope", "connection" |
| 2067 | +]) |
| 2068 | +@pytest.mark.asyncio |
| 2069 | +async def test_get_token_by_exchange_profile_extra_params_denylist(httpx_mock: HTTPXMock, denied_param): |
| 2070 | + """Test that denylisted extra parameters are silently ignored.""" |
| 2071 | + httpx_mock.add_response( |
| 2072 | + method="GET", |
| 2073 | + url="https://auth0.local/.well-known/openid-configuration", |
| 2074 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 2075 | + ) |
| 2076 | + httpx_mock.add_response( |
| 2077 | + method="POST", |
| 2078 | + url="https://auth0.local/oauth/token", |
| 2079 | + json={"access_token": "token", "expires_in": 3600} |
| 2080 | + ) |
| 2081 | + |
| 2082 | + api_client = ApiClient(ApiClientOptions( |
| 2083 | + domain="auth0.local", |
| 2084 | + audience="my-audience", |
| 2085 | + client_id="cid", |
| 2086 | + client_secret="csecret" |
| 2087 | + )) |
| 2088 | + |
| 2089 | + await api_client.get_token_by_exchange_profile( |
| 2090 | + subject_token="token", |
| 2091 | + subject_token_type="urn:example:type", |
| 2092 | + extra={denied_param: "should_be_ignored", "allowed_param": "value"} |
| 2093 | + ) |
| 2094 | + |
| 2095 | + request = httpx_mock.get_requests()[-1] |
| 2096 | + form_data = urllib.parse.parse_qs(request.content.decode()) |
| 2097 | + assert "allowed_param" in form_data |
| 2098 | + # Denylisted param should not override the original value |
| 2099 | + assert form_data["subject_token"] == ["token"] |
| 2100 | + |
| 2101 | + |
| 2102 | +@pytest.mark.asyncio |
| 2103 | +async def test_get_token_by_exchange_profile_extra_array_size_limit(httpx_mock: HTTPXMock): |
| 2104 | + """Test that extra array parameters enforce size limit.""" |
| 2105 | + httpx_mock.add_response( |
| 2106 | + method="GET", |
| 2107 | + url="https://auth0.local/.well-known/openid-configuration", |
| 2108 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 2109 | + ) |
| 2110 | + |
| 2111 | + api_client = ApiClient(ApiClientOptions( |
| 2112 | + domain="auth0.local", |
| 2113 | + audience="my-audience", |
| 2114 | + client_id="cid", |
| 2115 | + client_secret="csecret" |
| 2116 | + )) |
| 2117 | + |
| 2118 | + with pytest.raises(GetTokenByExchangeProfileError) as err: |
| 2119 | + await api_client.get_token_by_exchange_profile( |
| 2120 | + subject_token="token", |
| 2121 | + subject_token_type="urn:example:type", |
| 2122 | + extra={"large_array": [f"item{i}" for i in range(21)]} |
| 2123 | + ) |
| 2124 | + assert "exceeds maximum array size" in str(err.value) |
| 2125 | + assert "20" in str(err.value) |
| 2126 | + |
| 2127 | + |
| 2128 | +@pytest.mark.asyncio |
| 2129 | +async def test_get_token_by_exchange_profile_api_error(httpx_mock: HTTPXMock): |
| 2130 | + """Test handling of API errors from token endpoint.""" |
| 2131 | + httpx_mock.add_response( |
| 2132 | + method="GET", |
| 2133 | + url="https://auth0.local/.well-known/openid-configuration", |
| 2134 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 2135 | + ) |
| 2136 | + httpx_mock.add_response( |
| 2137 | + method="POST", |
| 2138 | + url="https://auth0.local/oauth/token", |
| 2139 | + status_code=400, |
| 2140 | + json={"error": "invalid_grant", "error_description": "Invalid subject token"} |
| 2141 | + ) |
| 2142 | + |
| 2143 | + api_client = ApiClient(ApiClientOptions( |
| 2144 | + domain="auth0.local", |
| 2145 | + audience="my-audience", |
| 2146 | + client_id="cid", |
| 2147 | + client_secret="csecret" |
| 2148 | + )) |
| 2149 | + |
| 2150 | + with pytest.raises(ApiError) as err: |
| 2151 | + await api_client.get_token_by_exchange_profile( |
| 2152 | + subject_token="token", |
| 2153 | + subject_token_type="urn:example:type" |
| 2154 | + ) |
| 2155 | + assert err.value.code == "invalid_grant" |
| 2156 | + assert err.value.status_code == 400 |
| 2157 | + |
| 2158 | + |
| 2159 | +@pytest.mark.asyncio |
| 2160 | +async def test_get_token_by_exchange_profile_timeout(httpx_mock: HTTPXMock): |
| 2161 | + """Test timeout handling.""" |
| 2162 | + httpx_mock.add_response( |
| 2163 | + method="GET", |
| 2164 | + url="https://auth0.local/.well-known/openid-configuration", |
| 2165 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 2166 | + ) |
| 2167 | + httpx_mock.add_exception(httpx.TimeoutException("timeout"), method="POST") |
| 2168 | + |
| 2169 | + api_client = ApiClient(ApiClientOptions( |
| 2170 | + domain="auth0.local", |
| 2171 | + audience="my-audience", |
| 2172 | + client_id="cid", |
| 2173 | + client_secret="csecret" |
| 2174 | + )) |
| 2175 | + |
| 2176 | + with pytest.raises(ApiError) as err: |
| 2177 | + await api_client.get_token_by_exchange_profile( |
| 2178 | + subject_token="token", |
| 2179 | + subject_token_type="urn:example:type" |
| 2180 | + ) |
| 2181 | + assert err.value.code == "timeout_error" |
| 2182 | + assert err.value.status_code == 504 |
| 2183 | + |
| 2184 | + |
| 2185 | +@pytest.mark.parametrize("response_data,error_msg", [ |
| 2186 | + ({}, "missing or invalid access_token"), |
| 2187 | + ({"access_token": ""}, "missing or invalid access_token"), |
| 2188 | + ({"access_token": None}, "missing or invalid access_token"), |
| 2189 | + ({"access_token": 123}, "missing or invalid access_token"), |
| 2190 | + ({"access_token": "token", "expires_in": "invalid"}, "expires_in is not an integer"), |
| 2191 | +]) |
| 2192 | +@pytest.mark.asyncio |
| 2193 | +async def test_get_token_by_exchange_profile_invalid_response(httpx_mock: HTTPXMock, response_data, error_msg): |
| 2194 | + """Test handling of invalid token endpoint responses.""" |
| 2195 | + httpx_mock.add_response( |
| 2196 | + method="GET", |
| 2197 | + url="https://auth0.local/.well-known/openid-configuration", |
| 2198 | + json={"token_endpoint": "https://auth0.local/oauth/token"} |
| 2199 | + ) |
| 2200 | + httpx_mock.add_response( |
| 2201 | + method="POST", |
| 2202 | + url="https://auth0.local/oauth/token", |
| 2203 | + json=response_data |
| 2204 | + ) |
| 2205 | + |
| 2206 | + api_client = ApiClient(ApiClientOptions( |
| 2207 | + domain="auth0.local", |
| 2208 | + audience="my-audience", |
| 2209 | + client_id="cid", |
| 2210 | + client_secret="csecret" |
| 2211 | + )) |
| 2212 | + |
| 2213 | + with pytest.raises(ApiError) as err: |
| 2214 | + await api_client.get_token_by_exchange_profile( |
| 2215 | + subject_token="token", |
| 2216 | + subject_token_type="urn:example:type" |
| 2217 | + ) |
| 2218 | + assert error_msg.lower() in str(err.value).lower() |
| 2219 | + assert err.value.status_code == 502 |
0 commit comments