-
Notifications
You must be signed in to change notification settings - Fork 157
/
Request OAuth2 access token from SAP using AAD JWT token.xml
254 lines (250 loc) · 21.8 KB
/
Request OAuth2 access token from SAP using AAD JWT token.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
<!-- The policy defined in this file provides an best-practice implementation of OAuth2SAMLBearerAssertion for SAP OData services.
The mechanism to map an IdP associated user (in this case Microsoft Entra ID (formerly Azure Active Directory - AAD) to a SAP backend user
is often referred to as SAP Principal Propagation.
-->
<!-- The policy shows how to exchange an Entra ID issued access token for an SAP issued Bearer token and forward it to the backend.
In addition to that it caches the tokens, so that clients can focus on app logic rather than SAP Principal Propagation and to scale the approach.
Furthermore, it handles the X-CSRF-Token handling for update requests. -->
<!-- Find further details in our blog series on the SAP community: https://blogs.sap.com/2021/08/12/.net-speaks-odata-too-how-to-implement-azure-app-service-with-sap-odata-gateway/
Find a Postman collection to test the policy flow here: https://github.com/MartinPankraz/AzureSAPODataReader/blob/master/Templates/AAD_APIM_SAP_Principal_Propagation.postman_collection.json
Find a video guide for the setup of SAP Principal Propagation here: https://github.com/MartinPankraz/SAP-MSTeams-Hero/blob/main/Towel-Bearer/103a-sap-principal-propagation-basics.md -->
<!-- Parameters: AADTenantId - format TENANT-GUID, can be optained from Azure portal view for Azure AD for instance -->
<!-- Parameters: AADRegisteredAppClientId - format APP-GUID, obtained during app registration -->
<!-- Parameters: AADRegisteredAppClientSecret - a URL encoded secret, obtained during app registration -->
<!-- Parameters: AADSAPResource - an id obtained during app registration. Our guide leverages the SAP SID for this (e.g. FS1) -->
<!-- Parameters: SAPOAuthClientID - an id obtained during OAuth setup on the SAP backend. Check SAP Transaction code SOAUTH2. -->
<!-- Parameters: SAPOAuthClientSecret - a URL encoded secret, obtained during OAuth setup on the SAP backend -->
<!-- Parameters: SAPOAuthScope - a text, obtained during OAuth setup on the SAP backend. Likely class name of the target OData service (e.g. ZEPM_REF_APPS_PROD_MAN_SRV_0001) -->
<!-- Parameters: SAPOAuthServerAdressForTokenEndpoint - format https://{{SAPOAuthServerAdressForTokenEndpoint}}/sap/bc/sec/oauth2/token -->
<!-- Parameters: SAPOAuthRefreshExpiry - a value in ms resembling the OAuth token refresh expiry time from SAP backend. Check SAP transaction SOAUTH2 for the value. -->
<!-- To create the parameters (APIM named values) used in this policy, we provided a Azure Cloud shell script here: https://github.com/MartinPankraz/AzureSAPODataReader/blob/master/Templates/UpdateAPIMwithVariablesForSAPPolicy.sh -->
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" failed-validation-httpcode="401" require-scheme="Bearer">
<openid-config url="https://login.microsoftonline.com/{{AADTenantId}}/.well-known/openid-configuration" />
<audiences>
<!-- adjust api URI for custom domains if required. -->
<!-- <audience>https://your-custom-domain-for-apim-as-regiesterd-in-aad </audience> -->
<audience>api://{{APIMAADRegisteredAppClientId}}</audience>
</audiences>
<issuers>
<!-- for v1 endpoints use https://sts.windows.net/{{AADTenantId}}/ -->
<issuer>https://login.microsoftonline.com/{{AADTenantId}}/v2.0</issuer>
</issuers>
<required-claims>
<claim name="scp" match="all" separator=" ">
<value>user_impersonation</value>
</claim>
</required-claims>
</validate-jwt>
<!-- avoid "br" encoding, because it breaks domain rewrite on outbound. SAP OData doesn't support br -->
<set-header name="Accept-Encoding" exists-action="override">
<value>gzip, deflate</value>
</set-header>
<set-variable name="APIMAADRegisteredAppClientId" value="{{APIMAADRegisteredAppClientId}}" />
<set-variable name="APIMAADRegisteredAppClientSecret" value="{{APIMAADRegisteredAppClientSecret}}" />
<set-variable name="AADSAPResource" value="{{AADSAPResource}}" />
<set-variable name="SAPOAuthClientID" value="{{SAPOAuthClientID}}" />
<set-variable name="SAPOAuthClientSecret" value="{{SAPOAuthClientSecret}}" />
<set-variable name="SAPOAuthScope" value="{{SAPOAuthScope}}" />
<set-variable name="SAPOAuthRefreshExpiry" value="{{SAPOAuthRefreshExpiry}}" />
<!-- check APIM cache for existing user SAP and refresh token for OData service -->
<cache-lookup-value key="@("SAPPrincipal" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" variable-name="SAPBearerToken" />
<cache-lookup-value key="@("SAPPrincipalRefresh" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" variable-name="SAPRefreshToken" />
<choose>
<!-- if SAP token is not in cache and also no refresh token available, get it from AAD and store both in cache -->
<when condition="@(!context.Variables.ContainsKey("SAPBearerToken") && !context.Variables.ContainsKey("SAPRefreshToken"))">
<!-- Exchange AAD Bearer token for AAD issued SAML token on behalf of logged in user -->
<send-request mode="new" response-variable-name="fetchSAMLAssertion" timeout="10" ignore-error="false">
<set-url>https://login.microsoftonline.com/{{AADTenantId}}/oauth2/v2.0/token</set-url>
<set-method>POST</set-method>
<set-header name="Content-Type" exists-action="override">
<value>application/x-www-form-urlencoded</value>
</set-header>
<set-body>@{
var _AADRegisteredAppClientId = context.Variables["APIMAADRegisteredAppClientId"];
var _AADRegisteredAppClientSecret = context.Variables["APIMAADRegisteredAppClientSecret"];
var _AADSAPResource = context.Variables["AADSAPResource"];
var assertion = context.Request.Headers.GetValueOrDefault("Authorization","").Replace("Bearer ","");
return $"grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={assertion}&client_id={_AADRegisteredAppClientId}&client_secret={_AADRegisteredAppClientSecret}&scope={_AADSAPResource}/.default&requested_token_use=on_behalf_of&requested_token_type=urn:ietf:params:oauth:token-type:saml2";
}</set-body>
</send-request>
<set-variable name="accessToken" value="@((string)((IResponse)context.Variables["fetchSAMLAssertion"]).Body.As<JObject>()["access_token"])" />
<!-- Get SAP backend issued Bearer token for presented AAD issued SAML token using OAuth2SAMLBearerAssertion flow. -->
<send-request mode="new" response-variable-name="fetchSAPBearer" timeout="10" ignore-error="false">
<set-url>https://{{SAPOAuthServerAdressForTokenEndpoint}}/sap/bc/sec/oauth2/token</set-url>
<set-method>POST</set-method>
<set-header name="Content-Type" exists-action="override">
<value>application/x-www-form-urlencoded</value>
</set-header>
<!-- Provide Authentication to SAP OAuth server. Check SAP transaction code SOAUTH2 for your individual configuration -->
<set-header name="Authorization" exists-action="override">
<value>@{
var _SAPOAuthClientID = context.Variables["SAPOAuthClientID"];
var _SAPOAuthClientSecret = context.Variables["SAPOAuthClientSecret"];
return "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_SAPOAuthClientID}:{_SAPOAuthClientSecret}"));
}</value>
</set-header>
<!-- Don't expose APIM subscription key to the backend. -->
<set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
<set-body>@{
var _SAPOAuthClientID = context.Variables["SAPOAuthClientID"];
var _SAPOAuthScope = context.Variables["SAPOAuthScope"];
var assertion2 = context.Variables["accessToken"];
return $"grant_type=urn:ietf:params:oauth:grant-type:saml2-bearer&assertion={assertion2}&client_id={_SAPOAuthClientID}&scope={_SAPOAuthScope}";
}</set-body>
</send-request>
<!-- Remember SAP Bearer tokens, deal with dictionary of objects -->
<set-variable name="SAPResponseObject" value="@(((IResponse)context.Variables["fetchSAPBearer"]).Body.As<JObject>())" />
<set-variable name="SAPBearerTokenExpiry" value="@(((JObject)context.Variables["SAPResponseObject"])["expires_in"].ToString())" />
<set-variable name="iSAPBearerTokenExpiry" value="@(int.Parse((string)context.Variables["SAPBearerTokenExpiry"]))" />
<set-variable name="SAPBearerToken" value="@(((JObject)context.Variables["SAPResponseObject"])["access_token"].ToString())" />
<set-variable name="SAPRefreshToken" value="@(((JObject)context.Variables["SAPResponseObject"])["refresh_token"].ToString())" />
<!-- take random time off the actual expiry to avoid login bursts (clustered expired tokens). Assuming a third of the total expiry as lower boundary -->
<set-variable name="RandomBackOffDelay" value="@(new Random().Next(0,(int)context.Variables["iSAPBearerTokenExpiry"]/3))" />
<!--cache Bearer and refresh token till expiry. We recommend 36 hours to mitigate "morning" login bursts efficiently. -->
<cache-store-value key="@("SAPPrincipal" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@((string)context.Variables["SAPBearerToken"])" duration="@((int)context.Variables["iSAPBearerTokenExpiry"] - (int)context.Variables["RandomBackOffDelay"])" />
<!-- optionally store duration for diagnostics endpoint
<cache-store-value key="@("SAPBearerDuration" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@((int)context.Variables["iSAPBearerTokenExpiry"] - (int)context.Variables["RandomBackOffDelay"])" duration="3600" />-->
<!-- verify refresh token expiry on SOAUTH2 transaction at SAP backend. We assume long lifetime and assign 10 times more by default. -->
<cache-store-value key="@("SAPPrincipalRefresh" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@((string)context.Variables["SAPRefreshToken"])" duration="@(int.Parse((string)context.Variables["SAPOAuthRefreshExpiry"]) - (int)context.Variables["RandomBackOffDelay"])" />
<!-- optionally store duration for diagnostics endpoint
<cache-store-value key="@("SAPRefreshDuration" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@(int.Parse((string)context.Variables["SAPOAuthRefreshExpiry"]) - (int)context.Variables["RandomBackOffDelay"])" duration="3600" />-->
</when>
<!-- if no SAP bearer token is available but a valid refresh token is present, use it to get a new bearer token -->
<when condition="@(!context.Variables.ContainsKey("SAPBearerToken") && context.Variables.ContainsKey("SAPRefreshToken"))">
<send-request mode="new" response-variable-name="fetchrefreshedSAPBearer" timeout="10" ignore-error="false">
<set-url>https://{{SAPOAuthServerAdressForTokenEndpoint}}/sap/bc/sec/oauth2/token</set-url>
<set-method>POST</set-method>
<set-header name="Content-Type" exists-action="override">
<value>application/x-www-form-urlencoded</value>
</set-header>
<!-- Provide Authentication to SAP OAuth server. Check SAP transaction code SOAUTH2 for your individual configuration -->
<set-header name="Authorization" exists-action="override">
<value>@{
var _SAPOAuthClientID = context.Variables["SAPOAuthClientID"];
var _SAPOAuthClientSecret = context.Variables["SAPOAuthClientSecret"];
return "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_SAPOAuthClientID}:{_SAPOAuthClientSecret}"));
}</value>
</set-header>
<set-body>@{
var _SAPOAuthClientID = context.Variables["SAPOAuthClientID"];
var _SAPOAuthScope = context.Variables["SAPOAuthScope"];
var _refreshToken = context.Variables["SAPRefreshToken"];
return $"grant_type=refresh_token&refresh_token={_refreshToken}&client_id={_SAPOAuthClientID}&scope={_SAPOAuthScope}";
}</set-body>
</send-request>
<!-- Remember SAP Bearer tokens. Consider handling http 400 Bad Request for invalid Refresh tokens -->
<set-variable name="SAPRefreshedResponseObject" value="@(((IResponse)context.Variables["fetchrefreshedSAPBearer"]).Body.As<JObject>())" />
<set-variable name="SAPBearerTokenExpiry" value="@(((JObject)context.Variables["SAPRefreshedResponseObject"])["expires_in"].ToString())" />
<set-variable name="iSAPBearerTokenExpiry" value="@(int.Parse((string)context.Variables["SAPBearerTokenExpiry"]))" />
<set-variable name="SAPBearerToken" value="@(((JObject)context.Variables["SAPRefreshedResponseObject"])["access_token"].ToString())" />
<set-variable name="SAPRefreshToken" value="@(((JObject)context.Variables["SAPRefreshedResponseObject"])["refresh_token"].ToString())" />
<!-- take random time off the actual expiry to avoid login bursts (clustered expired tokens). Assuming a third of the total expiry as lower boundary -->
<set-variable name="RandomBackOffDelay" value="@(new Random().Next(0,(int)context.Variables["iSAPBearerTokenExpiry"]/3))" />
<!--cache Bearer and refresh token till expiry. We recommend 36 hours to mitigate "morning" login bursts efficiently. -->
<cache-store-value key="@("SAPPrincipal" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@((string)context.Variables["SAPBearerToken"])" duration="@((int)context.Variables["iSAPBearerTokenExpiry"] - (int)context.Variables["RandomBackOffDelay"])" />
<!-- optionally store duration for diagnostics endpoint
<cache-store-value key="@("SAPBearerDuration" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@((int)context.Variables["iSAPBearerTokenExpiry"] - (int)context.Variables["RandomBackOffDelay"])" duration="3600" />-->
<!-- verify refresh token expiry on SOAUTH2 transaction at SAP backend. We assume long lifetime and assign 10 times more by default. -->
<cache-store-value key="@("SAPPrincipalRefresh" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@((string)context.Variables["SAPRefreshToken"])" duration="@(int.Parse((string)context.Variables["SAPOAuthRefreshExpiry"]) - (int)context.Variables["RandomBackOffDelay"])" />
<!-- optionally store duration for diagnostics endpoint
<cache-store-value key="@("SAPRefreshDuration" + context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Subject)" value="@(int.Parse((string)context.Variables["SAPOAuthRefreshExpiry"]) - (int)context.Variables["RandomBackOffDelay"])" duration="3600" />-->
</when>
</choose>
<!-- check CSRF -->
<choose>
<!-- CSRF-token only required for every operation other than GET or HEAD -->
<when condition="@(context.Request.Method != "GET" && context.Request.Method != "HEAD")">
<!-- Creating a HEAD subrequest to save request overhead and get the SAP CSRF token and cookie.-->
<send-request mode="new" response-variable-name="SAPCSRFToken" timeout="10" ignore-error="false">
<set-url>@(context.Request.Url.ToString())</set-url>
<set-method>HEAD</set-method>
<set-header name="X-CSRF-Token" exists-action="override">
<value>Fetch</value>
</set-header>
<set-header name="Authorization" exists-action="override">
<value>@("Bearer " + (string)context.Variables["SAPBearerToken"])</value>
</set-header>
</send-request>
<!-- Extract the token and cookie from the "SAPCSRFToken" and set as header in the POST request. -->
<choose>
<when condition="@(((IResponse)context.Variables["SAPCSRFToken"]).StatusCode == 200)">
<set-header name="X-CSRF-Token" exists-action="override">
<value>@(((IResponse)context.Variables["SAPCSRFToken"]).Headers.GetValueOrDefault("x-csrf-token"))</value>
</set-header>
<set-header name="Cookie" exists-action="override">
<value>@{
string rawcookie = ((IResponse)context.Variables["SAPCSRFToken"]).Headers.GetValueOrDefault("Set-Cookie");
string[] cookies = rawcookie.Split(';');
/* new session sends a XSRF cookie */
string xsrftoken = cookies.FirstOrDefault( ss => ss.Contains("sap-XSRF"));
/* existing sessions sends a SessionID. No other cases anticipated at this point. Please create a GitHub Pull-Request if you encounter uncovered settings. */
if(xsrftoken == null){
xsrftoken = cookies.FirstOrDefault( ss => ss.Contains("SAP_SESSIONID"));
}
return xsrftoken.Split(',')[1];}</value>
</set-header>
</when>
</choose>
</when>
</choose>
<set-header name="Authorization" exists-action="override">
<value>@("Bearer " + (string)context.Variables["SAPBearerToken"])</value>
</set-header>
<!-- Don't expose APIM subscription key to the backend. -->
<set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
<choose>
<!--introduce json format conversion only for non-metadata calls and only for GET operations. Otherwise bad request.-->
<when condition="@(!context.Request.Url.Path.Contains("/$metadata") && context.Request.Method == "GET")">
<set-query-parameter name="$format" exists-action="override">
<value>json</value>
</set-query-parameter>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
<!-- OPTIONAL: Ensure that APIM domain, port and path are reflected on the OData response from SAP
Otherwise deferred items like /toSalesOrders are represented by SAP known host and port. Example:
https://10.10.10.10:44300/sap/opu/odata/iwbep/gwsample_basic/BusinessPartnerSet('0100000000')/ToSalesOrders vs.
https://demo-sap-apim.azure-api.net:443/my-prefix/sap/opu/odata/iwbep/gwsample_basic/BusinessPartnerSet('0100000000')/ToSalesOrders
URL rewrite in body required for returned OData paths.
- Consider adding "/" + "context.Api.Version" to reflect your versioning scheme: https://learn.microsoft.com/azure/api-management/api-management-versions
- Consider Using expression "ToString().ToUpper()" to cater for upper case SAP host names. See SAP Note 129997 and 611361 that discuss the SAP ABAP settings for case sensive host names for reference.
context.Api.ServiceUrl.Host.ToString().ToUpper()
-->
<find-and-replace from="@(context.Api.ServiceUrl.Host +":"+ context.Api.ServiceUrl.Port)" to="@(context.Request.OriginalUrl.Host + ":" + context.Request.OriginalUrl.Port + context.Api.Path)" />
</outbound>
<on-error>
<base />
<set-header name="ErrorSource" exists-action="override">
<value>@(context.LastError.Source)</value>
</set-header>
<set-header name="ErrorReason" exists-action="override">
<value>@(context.LastError.Reason)</value>
</set-header>
<set-header name="ErrorMessage" exists-action="override">
<value>@(context.LastError.Message)</value>
</set-header>
<set-header name="ErrorScope" exists-action="override">
<value>@(context.LastError.Scope)</value>
</set-header>
<set-header name="ErrorSection" exists-action="override">
<value>@(context.LastError.Section)</value>
</set-header>
<set-header name="ErrorPath" exists-action="override">
<value>@(context.LastError.Path)</value>
</set-header>
<set-header name="ErrorPolicyId" exists-action="override">
<value>@(context.LastError.PolicyId)</value>
</set-header>
<set-header name="ErrorStatusCode" exists-action="override">
<value>@(context.Response.StatusCode.ToString())</value>
</set-header>
</on-error>
</policies>