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}