001/*
002 * oauth2-oidc-sdk
003 *
004 * Copyright 2012-2021, 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.openid.connect.sdk.assurance.evidences.attachment;
019
020
021import java.io.IOException;
022import java.net.URI;
023import java.security.NoSuchAlgorithmException;
024import java.util.Objects;
025
026import net.jcip.annotations.Immutable;
027import net.minidev.json.JSONObject;
028
029import com.nimbusds.jose.util.Base64;
030import com.nimbusds.oauth2.sdk.ParseException;
031import com.nimbusds.oauth2.sdk.http.HTTPRequest;
032import com.nimbusds.oauth2.sdk.http.HTTPResponse;
033import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
034import com.nimbusds.oauth2.sdk.util.JSONObjectUtils;
035import com.nimbusds.oauth2.sdk.util.StringUtils;
036
037
038/**
039 * External attachment. Provides a {@link #retrieveContent method} to retrieve
040 * the remote content and verify its digest.
041 *
042 * <p>Related specifications:
043 *
044 * <ul>
045 *     <li>OpenID Connect for Identity Assurance 1.0, section 5.1.2.2.
046 * </ul>
047 */
048@Immutable
049public class ExternalAttachment extends Attachment {
050        
051        
052        /**
053         * The attachment URL.
054         */
055        private final URI url;
056        
057        
058        /**
059         * Optional access token of type Bearer for retrieving the attachment.
060         */
061        private final BearerAccessToken accessToken;
062        
063        
064        /**
065         * Number of seconds until the attachment becomes unavailable and / or
066         * the access token becomes invalid. Zero or negative is not specified.
067         */
068        private final long expiresIn;
069        
070        
071        /**
072         * The cryptographic digest.
073         */
074        private final Digest digest;
075        
076        
077        /**
078         * Creates a new external attachment.
079         *
080         * @param url         The attachment URL. Must not be {@code null}.
081         * @param accessToken Optional access token of type Bearer for
082         *                    retrieving the attachment, {@code null} if none.
083         * @param expiresIn   Number of seconds until the attachment becomes
084         *                    unavailable and / or the access token becomes
085         *                    invalid. Zero or negative if not specified.
086         * @param digest      The cryptographic digest for the document
087         *                    content. Must not be {@code null}.
088         * @param description The description, {@code null} if not specified.
089         */
090        public ExternalAttachment(final URI url,
091                                  final BearerAccessToken accessToken,
092                                  final long expiresIn,
093                                  final Digest digest,
094                                  final String description) {
095                super(AttachmentType.EXTERNAL, description);
096                
097                Objects.requireNonNull(url);
098                this.url = url;
099                
100                this.accessToken = accessToken;
101                
102                this.expiresIn = expiresIn;
103                
104                Objects.requireNonNull(digest);
105                this.digest = digest;
106        }
107        
108        
109        /**
110         * Returns the attachment URL.
111         *
112         * @return The attachment URL.
113         */
114        public URI getURL() {
115                return url;
116        }
117        
118        
119        /**
120         * Returns the optional access token of type Bearer for retrieving the
121         * attachment.
122         *
123         * @return The bearer access token, {@code null} if not specified.
124         */
125        public BearerAccessToken getBearerAccessToken() {
126                return accessToken;
127        }
128        
129        
130        /**
131         * Returns the number of seconds until the attachment becomes
132         * unavailable and / or the access token becomes invalid.
133         *
134         * @return The number of seconds until the attachment becomes
135         *         unavailable and / or the access token becomes invalid. Zero
136         *         or negative if not specified.
137         */
138        public long getExpiresIn() {
139                return expiresIn;
140        }
141        
142        
143        /**
144         * Returns the cryptographic digest for the document content.
145         *
146         * @return The cryptographic digest.
147         */
148        public Digest getDigest() {
149                return digest;
150        }
151        
152        
153        /**
154         * Retrieves the external attachment content and verifies its digest.
155         *
156         * @param httpConnectTimeout The HTTP connect timeout, in milliseconds.
157         *                           Zero implies no timeout. Must not be
158         *                           negative.
159         * @param httpReadTimeout    The HTTP response read timeout, in
160         *                           milliseconds. Zero implies no timeout.
161         *                           Must not be negative.
162         *
163         * @return The retrieved content.
164         *
165         * @throws IOException              If retrieval of the content failed.
166         * @throws NoSuchAlgorithmException If the hash algorithm for the
167         *                                  digest isn't supported.
168         * @throws DigestMismatchException  If the computed digest for the
169         *                                  retrieved document doesn't match
170         *                                  the expected.
171         */
172        public Content retrieveContent(final int httpConnectTimeout, final int httpReadTimeout)
173                throws IOException, NoSuchAlgorithmException, DigestMismatchException {
174                
175                HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.GET, getURL());
176                if (getBearerAccessToken() != null) {
177                        httpRequest.setAuthorization(getBearerAccessToken().toAuthorizationHeader());
178                }
179                httpRequest.setConnectTimeout(httpConnectTimeout);
180                httpRequest.setReadTimeout(httpReadTimeout);
181                
182                HTTPResponse httpResponse = httpRequest.send();
183                try {
184                        httpResponse.ensureStatusCode(200);
185                } catch (ParseException e) {
186                        throw new IOException(e.getMessage(), e);
187                }
188                
189                if (httpResponse.getEntityContentType() == null) {
190                        throw new IOException("Missing Content-Type header in HTTP response: " + url);
191                }
192                
193                if (StringUtils.isBlank(httpResponse.getContent())) {
194                        throw new IOException("The HTTP response has no content: " + url);
195                }
196                
197                // Trim whitespace to ensure digest gets computed over base64 text only
198                Base64 contentBase64 = new Base64(httpResponse.getContent().trim());
199                
200                if (! getDigest().matches(contentBase64)) {
201                        throw new DigestMismatchException("The computed " + digest.getHashAlgorithm() + " digest for the retrieved content doesn't match the expected: " + getURL());
202                }
203                
204                return new Content(httpResponse.getEntityContentType(), contentBase64, getDescriptionString());
205        }
206        
207        
208        @Override
209        public JSONObject toJSONObject() {
210                
211                JSONObject jsonObject = super.toJSONObject();
212                
213                jsonObject.put("url", getURL().toString());
214                if (getBearerAccessToken() != null) {
215                        jsonObject.put("access_token", getBearerAccessToken().getValue());
216                }
217                if (expiresIn > 0) {
218                        jsonObject.put("expires_in", getExpiresIn());
219                }
220                jsonObject.put("digest", getDigest().toJSONObject());
221                return jsonObject;
222        }
223        
224        
225        @Override
226        public boolean equals(Object o) {
227                if (this == o) return true;
228                if (!(o instanceof ExternalAttachment)) return false;
229                if (!super.equals(o)) return false;
230                ExternalAttachment that = (ExternalAttachment) o;
231                return getExpiresIn() == that.getExpiresIn() &&
232                        url.equals(that.url) &&
233                        Objects.equals(accessToken, that.accessToken) &&
234                        getDigest().equals(that.getDigest());
235        }
236        
237        
238        @Override
239        public int hashCode() {
240                return Objects.hash(super.hashCode(), url, accessToken, getExpiresIn(), getDigest());
241        }
242        
243        
244        /**
245         * Parses an external attachment from the specified JSON object.
246         *
247         * @param jsonObject The JSON object. Must not be {@code null}.
248         *
249         * @return The external attachment.
250         *
251         * @throws ParseException If parsing failed.
252         */
253        public static ExternalAttachment parse(final JSONObject jsonObject)
254                throws ParseException {
255                
256                URI url = JSONObjectUtils.getURI(jsonObject, "url");
257                
258                long expiresIn = 0;
259                if (jsonObject.get("expires_in") != null) {
260                        
261                        expiresIn = JSONObjectUtils.getLong(jsonObject, "expires_in");
262                        
263                        if (expiresIn < 1) {
264                                throw new ParseException("The expires_in parameter must be a positive integer");
265                        }
266                }
267                
268                BearerAccessToken accessToken = null;
269                if (jsonObject.get("access_token") != null) {
270                        
271                        String tokenValue = JSONObjectUtils.getString(jsonObject, "access_token");
272                        
273                        if (expiresIn > 0) {
274                                accessToken = new BearerAccessToken(tokenValue, expiresIn, null);
275                        } else {
276                                accessToken = new BearerAccessToken(tokenValue);
277                        }
278                }
279                
280                String description = JSONObjectUtils.getString(jsonObject, "desc", null);
281                
282                Digest digest = Digest.parse(JSONObjectUtils.getJSONObject(jsonObject, "digest"));
283                
284                return new ExternalAttachment(url, accessToken, expiresIn, digest, description);
285        }
286}