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.util.date;
019
020
021import java.text.SimpleDateFormat;
022import java.util.Date;
023import java.util.Objects;
024import java.util.TimeZone;
025import java.util.regex.Matcher;
026import java.util.regex.Pattern;
027
028import com.nimbusds.jwt.util.DateUtils;
029import com.nimbusds.oauth2.sdk.ParseException;
030
031
032/**
033 * Date with optional timezone offset. Supports basic ISO 8601 formatting and
034 * parsing.
035 */
036public class DateWithTimeZoneOffset {
037        
038        
039        /**
040         * The date.
041         */
042        private final Date date;
043        
044        
045        /**
046         * The time zone offset in minutes relative to UTC.
047         */
048        private final int tzOffsetMinutes;
049        
050        
051        /**
052         * {@code true} if the date is in UTC.
053         */
054        private final boolean isUTC;
055        
056        
057        /**
058         * Creates a new date in UTC, to be {@link #toISO8601String() output}
059         * with {@code Z} timezone designation.
060         *
061         * @param date The date. Must not be {@code null}.
062         */
063        public DateWithTimeZoneOffset(final Date date) {
064                if (date == null) {
065                        throw new IllegalArgumentException("The date must not be null");
066                }
067                this.date = date;
068                tzOffsetMinutes = 0;
069                isUTC = true;
070        }
071        
072        
073        /**
074         * Creates a new date with timezone offset.
075         *
076         * @param date            The date. Must not be {@code null}.
077         * @param tzOffsetMinutes The time zone offset in minutes relative to
078         *                        UTC, zero if none. Must be less than
079         *                        {@code +/- 12 x 60}.
080         */
081        public DateWithTimeZoneOffset(final Date date, final int tzOffsetMinutes) {
082                if (date == null) {
083                        throw new IllegalArgumentException("The date must not be null");
084                }
085                this.date = date;
086                if (tzOffsetMinutes >= 12*60 || tzOffsetMinutes <= -12*60) {
087                        throw new IllegalArgumentException("The time zone offset must be less than +/- 12 x 60 minutes");
088                }
089                this.tzOffsetMinutes = tzOffsetMinutes;
090                isUTC = false;
091        }
092        
093        
094        /**
095         * Creates a new date with timezone offset.
096         *
097         * @param date The date. Must not be {@code null}.
098         * @param tz   The time zone to determine the time zone offset.
099         */
100        public DateWithTimeZoneOffset(final Date date, final TimeZone tz) {
101                this(date, tz.getOffset(date.getTime()) / 60_000);
102        }
103        
104        
105        /**
106         * Returns the date.
107         *
108         * @return The date.
109         */
110        public Date getDate() {
111                return date;
112        }
113        
114        
115        /**
116         * Returns {@code true} if the date is in UTC.
117         *
118         * @return {@code true} if the date is in UTC, else the time zone
119         *         {@link #getTimeZoneOffsetMinutes offset} applies.
120         */
121        public boolean isUTC() {
122                return isUTC;
123        }
124        
125        
126        /**
127         * Returns the time zone offset in minutes relative to UTC.
128         *
129         * @return The time zone offset in minutes relative to UTC, zero if
130         *         none.
131         */
132        public int getTimeZoneOffsetMinutes() {
133                return tzOffsetMinutes;
134        }
135        
136        
137        /**
138         * Returns an ISO 8601 representation in
139         * {@code YYYY-MM-DDThh:mm:ssZ} or {@code YYYY-MM-DDThh:mm:ss±hh:mm}
140         * format
141         *
142         * <p>Example: {@code 2019-11-01T18:19:43+03:00}
143         *
144         * @return The ISO 8601 representation.
145         */
146        public String toISO8601String() {
147                
148                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
149                
150                // Hack to format date/time with TZ offset
151                TimeZone tz = TimeZone.getTimeZone("UTC");
152                sdf.setTimeZone(tz);
153                
154                long localTimeSeconds = DateUtils.toSecondsSinceEpoch(date);
155                localTimeSeconds = localTimeSeconds + (tzOffsetMinutes * 60L);
156                
157                String out = sdf.format(DateUtils.fromSecondsSinceEpoch(localTimeSeconds));
158                
159                if (isUTC()) {
160                        return out + "Z";
161                }
162                
163                // Append TZ offset
164                int tzOffsetWholeHours = tzOffsetMinutes / 60;
165                int tzOffsetRemainderMinutes = tzOffsetMinutes - (tzOffsetWholeHours * 60);
166
167                if (tzOffsetMinutes == 0) {
168                        return out + "+00:00";
169                }
170
171                if (tzOffsetWholeHours > 0) {
172                        out += "+" + (tzOffsetWholeHours < 10 ? "0" : "") + Math.abs(tzOffsetWholeHours);
173                } else if (tzOffsetWholeHours < 0) {
174                        out += "-" + (tzOffsetWholeHours > -10 ? "0" : "") + Math.abs(tzOffsetWholeHours);
175                } else {
176                        if (tzOffsetMinutes > 0) {
177                                out += "+00";
178                        } else {
179                                out += "-00";
180                        }
181                }
182
183                out += ":";
184
185                if (tzOffsetRemainderMinutes > 0) {
186                        out += (tzOffsetRemainderMinutes < 10 ? "0" : "") + tzOffsetRemainderMinutes;
187                } else if (tzOffsetRemainderMinutes < 0) {
188                        out += (tzOffsetRemainderMinutes > -10 ? "0" : "") + Math.abs(tzOffsetRemainderMinutes);
189                } else {
190                        out += "00";
191                }
192                
193                return out;
194        }
195        
196        
197        @Override
198        public String toString() {
199                return toISO8601String();
200        }
201        
202        
203        @Override
204        public boolean equals(Object o) {
205                if (this == o) return true;
206                if (!(o instanceof DateWithTimeZoneOffset)) return false;
207                DateWithTimeZoneOffset that = (DateWithTimeZoneOffset) o;
208                return tzOffsetMinutes == that.tzOffsetMinutes &&
209                        getDate().equals(that.getDate());
210        }
211        
212        
213        @Override
214        public int hashCode() {
215                return Objects.hash(getDate(), tzOffsetMinutes);
216        }
217        
218        
219        /**
220         * Parses an ISO 8601 representation in
221         * {@code YYYY-MM-DDThh:mm:ss±hh:mm} format.
222         *
223         * <p>Example: {@code 2019-11-01T18:19:43+03:00}
224         *
225         * @param s The string to parse.
226         *
227         * @return The date with timezone offset.
228         *
229         * @throws ParseException If parsing failed.
230         */
231        public static DateWithTimeZoneOffset parseISO8601String(final String s)
232                throws ParseException  {
233                
234                String stringToParse = s;
235                
236                if (Pattern.compile(".*[\\+\\-][\\d]{2}$").matcher(s).matches()) {
237                        // append minutes to hour resolution offset TZ
238                        stringToParse += ":00";
239                }
240                
241                Matcher m = Pattern.compile("(.*[\\+\\-][\\d]{2})(\\d{2})$").matcher(stringToParse);
242                if (m.matches()) {
243                        // insert colon between hh and mm offset
244                        stringToParse = m.group(1) + ":" + m.group(2);
245                }
246                
247                m = Pattern.compile("(.*\\d{2}:\\d{2}:\\d{2})([\\+\\-Z].*)$").matcher(stringToParse);
248                if (m.matches()) {
249                        // insert zero milliseconds
250                        stringToParse = m.group(1) + ".000" + m.group(2);
251                }
252                
253                int colonCount = stringToParse.length() - stringToParse.replace(":", "").length();
254                
255                Date date;
256                try {
257                        if (colonCount == 1) {
258                                date = new SimpleDateFormat("yyyy-MM-dd'T'HH:mmXXX").parse(stringToParse);
259                        } else {
260                                date = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").parse(stringToParse);
261                        }
262                } catch (java.text.ParseException e) {
263                        throw new ParseException(e.getMessage());
264                }
265                
266                if (stringToParse.trim().endsWith("Z") || stringToParse.trim().endsWith("z")) {
267                        return new DateWithTimeZoneOffset(date); // UTC
268                }
269                
270                int tzOffsetMinutes;
271                try {
272                        // E.g. +03:00
273                        String offsetSpec = stringToParse.substring("2019-11-01T06:19:43.000".length());
274                        int hoursOffset = Integer.parseInt(offsetSpec.substring(0, 3));
275                        int minutesOffset = Integer.parseInt(offsetSpec.substring(4));
276                        if (offsetSpec.startsWith("+")) {
277                                tzOffsetMinutes = hoursOffset * 60 + minutesOffset;
278                        } else {
279                                // E.g. -03:00, -00:30
280                                tzOffsetMinutes = hoursOffset * 60 - minutesOffset;
281                        }
282                } catch (Exception e) {
283                        throw new ParseException("Unexpected timezone offset: " + s);
284                }
285                
286                return new DateWithTimeZoneOffset(date, tzOffsetMinutes);
287        }
288}