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}