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