001/*
002 * oauth2-oidc-sdk
003 *
004 * Copyright 2012-2016, Connect2id Ltd and contributors.
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use
007 * this file except in compliance with the License. You may obtain a copy of the
008 * License at
009 *
010 *    http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software distributed
013 * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
014 * CONDITIONS OF ANY KIND, either express or implied. See the License for the
015 * specific language governing permissions and limitations under the License.
016 */
017
018package com.nimbusds.oauth2.sdk.http;
019
020
021import com.nimbusds.common.contenttype.ContentType;
022import com.nimbusds.oauth2.sdk.ParseException;
023import com.nimbusds.oauth2.sdk.util.URLUtils;
024import com.nimbusds.oauth2.sdk.util.X509CertificateUtils;
025import jakarta.servlet.ServletRequest;
026import jakarta.servlet.http.HttpServletRequest;
027import jakarta.servlet.http.HttpServletResponse;
028import net.jcip.annotations.ThreadSafe;
029
030import java.io.BufferedReader;
031import java.io.IOException;
032import java.io.PrintWriter;
033import java.net.MalformedURLException;
034import java.net.URL;
035import java.security.cert.X509Certificate;
036import java.util.Enumeration;
037import java.util.LinkedList;
038import java.util.List;
039import java.util.Map;
040
041
042/**
043 * HTTP Jakarta Servlet utilities.
044 *
045 * <p>Requires the optional {@code jakarta.servlet} dependency (or newer):
046 *
047 * <pre>
048 * jakarta.servlet:jakarta.servlet-api:5.0.0
049 * </pre>
050 */
051@ThreadSafe
052public class JakartaServletUtils {
053
054
055        /**
056         * Reconstructs the request URL string for the specified servlet
057         * request. The host part is always the local IP address. The query
058         * string and fragment is always omitted.
059         *
060         * @param request The servlet request. Must not be {@code null}.
061         *
062         * @return The reconstructed request URL string.
063         */
064        private static String reconstructRequestURLString(final HttpServletRequest request) {
065
066                StringBuilder sb = new StringBuilder("http");
067
068                if (request.isSecure())
069                        sb.append('s');
070
071                sb.append("://");
072
073                String localAddress = request.getLocalAddr();
074                
075                if (localAddress == null || localAddress.trim().isEmpty()) {
076                        // Unknown local address (hostname / IP address)
077                } else if (localAddress.contains(".")) {
078                        // IPv3 address
079                        sb.append(localAddress);
080                } else if (localAddress.contains(":")) {
081                        
082                        // IPv6 address, see RFC 2732
083                        
084                        // Handle non-compliant "Jetty" formatting of IPv6 address:
085                        // https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/issues/376/
086                        
087                        if (! localAddress.startsWith("[")) {
088                                sb.append('[');
089                        }
090                        
091                        sb.append(localAddress);
092                        
093                        if (! localAddress.endsWith("]")) {
094                                sb.append(']');
095                        }
096                }
097
098                if (! request.isSecure() && request.getLocalPort() != 80) {
099                        // HTTP plain at port other than 80
100                        sb.append(':');
101                        sb.append(request.getLocalPort());
102                }
103
104                if (request.isSecure() && request.getLocalPort() != 443) {
105                        // HTTPS at port other than 443 (default TLS)
106                        sb.append(':');
107                        sb.append(request.getLocalPort());
108                }
109
110                String path = request.getRequestURI();
111
112                if (path != null)
113                        sb.append(path);
114
115                return sb.toString();
116        }
117
118
119        /**
120         * Creates a new HTTP request from the specified HTTP servlet request.
121         *
122         * <p><strong>Warning about servlet filters: </strong> Processing of
123         * HTTP POST and PUT requests requires the entity body to be available
124         * for reading from the {@link HttpServletRequest}. If you're getting
125         * unexpected exceptions, please ensure the entity body is not consumed
126         * or modified by an upstream servlet filter.
127         *
128         * @param sr The servlet request. Must not be {@code null}.
129         *
130         * @return The HTTP request.
131         *
132         * @throws IllegalArgumentException The the servlet request method is
133         *                                  not GET, POST, PUT or DELETE or the
134         *                                  content type header value couldn't
135         *                                  be parsed.
136         * @throws IOException              For a POST or PUT body that
137         *                                  couldn't be read due to an I/O
138         *                                  exception.
139         */
140        public static HTTPRequest createHTTPRequest(final HttpServletRequest sr)
141                throws IOException {
142
143                return createHTTPRequest(sr, -1);
144        }
145
146
147        /**
148         * Creates a new HTTP request from the specified HTTP servlet request.
149         *
150         * <p><strong>Warning about servlet filters: </strong> Processing of
151         * HTTP POST and PUT requests requires the entity body to be available
152         * for reading from the {@link HttpServletRequest}. If you're getting
153         * unexpected exceptions, please ensure the entity body is not consumed
154         * or modified by an upstream servlet filter.
155         *
156         * @param sr              The servlet request. Must not be
157         *                        {@code null}.
158         * @param maxEntityLength The maximum entity length to accept, -1 for
159         *                        no limit.
160         *
161         * @return The HTTP request.
162         *
163         * @throws IllegalArgumentException The the servlet request method is
164         *                                  not GET, POST, PUT or DELETE or the
165         *                                  content type header value couldn't
166         *                                  be parsed.
167         * @throws IOException              For a POST or PUT body that
168         *                                  couldn't be read due to an I/O
169         *                                  exception.
170         */
171        public static HTTPRequest createHTTPRequest(final HttpServletRequest sr, final long maxEntityLength)
172                throws IOException {
173
174                HTTPRequest.Method method = HTTPRequest.Method.valueOf(sr.getMethod().toUpperCase());
175
176                String urlString = reconstructRequestURLString(sr);
177
178                URL url;
179                try {
180                        url = new URL(urlString);
181                } catch (MalformedURLException e) {
182                        throw new IllegalArgumentException("Invalid request URL: " + e.getMessage() + ": " + urlString, e);
183                }
184
185                HTTPRequest request = new HTTPRequest(method, url);
186
187                try {
188                        request.setContentType(sr.getContentType());
189
190                } catch (ParseException e) {
191
192                        throw new IllegalArgumentException("Invalid Content-Type header value: " + e.getMessage(), e);
193                }
194
195                Enumeration<String> headerNames = sr.getHeaderNames();
196
197                while (headerNames.hasMoreElements()) {
198                        
199                        final String headerName = headerNames.nextElement();
200                        
201                        Enumeration<String> headerValues = sr.getHeaders(headerName);
202                        
203                        if (headerValues == null || ! headerValues.hasMoreElements())
204                                continue;
205                        
206                        List<String> headerValuesList = new LinkedList<>();
207                        while (headerValues.hasMoreElements()) {
208                                headerValuesList.add(headerValues.nextElement());
209                        }
210                        
211                        request.setHeader(headerName, headerValuesList.toArray(new String[0]));
212                }
213
214                if (method.equals(HTTPRequest.Method.GET) || method.equals(HTTPRequest.Method.DELETE)) {
215
216                        request.appendQueryString(sr.getQueryString());
217
218                } else if (method.equals(HTTPRequest.Method.POST) || method.equals(HTTPRequest.Method.PUT)) {
219
220                        // Impossible to read application/x-www-form-urlencoded request content on which parameters
221                        // APIs have been used. To be safe we recreate the content based on the parameters in this case.
222                        // See issues
223                        // https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/issues/184
224                        // https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions/issues/186
225                        if (ContentType.APPLICATION_URLENCODED.matches(request.getEntityContentType())) {
226
227                                // Recreate the content based on parameters
228                                request.setBody(URLUtils.serializeParametersAlt(sr.getParameterMap()));
229                        } else {
230                                // read body
231                                StringBuilder body = new StringBuilder(256);
232
233                                BufferedReader reader = sr.getReader();
234
235                                char[] cbuf = new char[256];
236
237                                int readChars;
238
239                                while ((readChars = reader.read(cbuf)) != -1) {
240
241                                        body.append(cbuf, 0, readChars);
242
243                                        if (maxEntityLength > 0 && body.length() > maxEntityLength) {
244                                                throw new IOException(
245                                                        "Request entity body is too large, limit is " + maxEntityLength + " chars");
246                                        }
247                                }
248
249                                reader.close();
250                                request.setBody(body.toString());
251                        }
252                }
253                
254                // Extract validated client X.509 if we have mutual TLS
255                X509Certificate cert = extractClientX509Certificate(sr);
256                if (cert != null) {
257                        request.setClientX509Certificate(cert);
258                        request.setClientX509CertificateSubjectDN(cert.getSubjectDN() != null ? cert.getSubjectDN().getName() : null);
259                        
260                        // The root DN cannot be reliably set for a CA-signed
261                        // client cert from a servlet request, unless self-issued
262                        if (X509CertificateUtils.hasMatchingIssuerAndSubject(cert)) {
263                                request.setClientX509CertificateRootDN(cert.getIssuerDN() != null ? cert.getIssuerDN().getName() : null);
264                        }
265                }
266                
267                // Extract client IP address, X-Forwarded-For not checked
268                request.setClientIPAddress(sr.getRemoteAddr());
269
270                return request;
271        }
272
273
274        /**
275         * Applies the status code, headers and content of the specified HTTP
276         * response to a HTTP servlet response.
277         *
278         * @param httpResponse    The HTTP response. Must not be {@code null}.
279         * @param servletResponse The HTTP servlet response. Must not be
280         *                        {@code null}.
281         *
282         * @throws IOException If the response content couldn't be written.
283         */
284        public static void applyHTTPResponse(final HTTPResponse httpResponse,
285                                             final HttpServletResponse servletResponse)
286                throws IOException {
287
288                // Set the status code
289                servletResponse.setStatus(httpResponse.getStatusCode());
290
291
292                // Set the headers, but only if explicitly specified
293                for (Map.Entry<String,List<String>> header : httpResponse.getHeaderMap().entrySet()) {
294                        for (String headerValue: header.getValue()) {
295                                servletResponse.addHeader(header.getKey(), headerValue);
296                        }
297                }
298
299                if (httpResponse.getEntityContentType() != null)
300                        servletResponse.setContentType(httpResponse.getEntityContentType().toString());
301
302
303                // Write out the content
304                if (httpResponse.getBody() != null) {
305                        PrintWriter writer = servletResponse.getWriter();
306                        writer.print(httpResponse.getBody());
307                        writer.close();
308                }
309        }
310        
311        
312        /**
313         * Extracts the client's X.509 certificate from the specified servlet
314         * request. The first found certificate is returned, if any.
315         *
316         * @param servletRequest The HTTP servlet request. Must not be
317         *                       {@code null}.
318         *
319         * @return The first client X.509 certificate, {@code null} if none is
320         *         found.
321         */
322        public static X509Certificate extractClientX509Certificate(final ServletRequest servletRequest) {
323                
324                X509Certificate[] certs = (X509Certificate[]) servletRequest.getAttribute("jakarta.servlet.request.X509Certificate");
325
326                if (certs == null || certs.length == 0) {
327                        return null;
328                }
329                
330                return certs[0];
331        }
332
333
334        /**
335         * Prevents public instantiation.
336         */
337        private JakartaServletUtils() { }
338}