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