001/* 002 * Copyright 2018 The AppAuth for Android Authors. All Rights Reserved. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 005 * in compliance with the License. You may obtain a copy of the License at 006 * 007 * http://www.apache.org/licenses/LICENSE-2.0 008 * 009 * Unless required by applicable law or agreed to in writing, software distributed under the 010 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 011 * express or implied. See the License for the specific language governing permissions and 012 * limitations under the License. 013 */ 014 015package net.openid.appauth; 016 017import static net.openid.appauth.AdditionalParamsProcessor.builtInParams; 018 019import android.net.Uri; 020import android.text.TextUtils; 021import android.util.Base64; 022import androidx.annotation.NonNull; 023import androidx.annotation.Nullable; 024import androidx.annotation.VisibleForTesting; 025 026import net.openid.appauth.AuthorizationException.GeneralErrors; 027import org.json.JSONException; 028import org.json.JSONObject; 029 030import java.util.ArrayList; 031import java.util.Collections; 032import java.util.List; 033import java.util.Map; 034import java.util.Set; 035 036/** 037 * An OpenID Connect ID Token. Contains claims about the authentication of an End-User by an 038 * Authorization Server. Supports parsing ID Tokens from JWT Compact Serializations and validation 039 * according to the OpenID Connect specification. 040 * 041 * @see "OpenID Connect Core ID Token, Section 2 042 * <http://openid.net/specs/openid-connect-core-1_0.html#IDToken>" 043 * @see "OpenID Connect Core ID Token Validation, Section 3.1.3.7 044 * <http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation>" 045 */ 046public class IdToken { 047 048 private static final String KEY_ISSUER = "iss"; 049 private static final String KEY_SUBJECT = "sub"; 050 private static final String KEY_AUDIENCE = "aud"; 051 private static final String KEY_EXPIRATION = "exp"; 052 private static final String KEY_ISSUED_AT = "iat"; 053 private static final String KEY_NONCE = "nonce"; 054 private static final String KEY_AUTHORIZED_PARTY = "azp"; 055 private static final Long MILLIS_PER_SECOND = 1000L; 056 private static final Long TEN_MINUTES_IN_SECONDS = 600L; 057 058 private static final Set<String> BUILT_IN_CLAIMS = builtInParams( 059 KEY_ISSUER, 060 KEY_SUBJECT, 061 KEY_AUDIENCE, 062 KEY_EXPIRATION, 063 KEY_ISSUED_AT, 064 KEY_NONCE, 065 KEY_AUTHORIZED_PARTY); 066 067 /** 068 * Issuer Identifier for the Issuer of the response. 069 */ 070 @NonNull 071 public final String issuer; 072 073 /** 074 * Subject Identifier. A locally unique and never reassigned identifier within the Issuer 075 * for the End-User. 076 */ 077 @NonNull 078 public final String subject; 079 080 /** 081 * Audience(s) that this ID Token is intended for. 082 */ 083 @NonNull 084 public final List<String> audience; 085 086 /** 087 * Expiration time on or after which the ID Token MUST NOT be accepted for processing. 088 */ 089 @NonNull 090 public final Long expiration; 091 092 /** 093 * Time at which the JWT was issued. 094 */ 095 @NonNull 096 public final Long issuedAt; 097 098 /** 099 * String value used to associate a Client session with an ID Token, 100 * and to mitigate replay attacks. 101 */ 102 @Nullable 103 public final String nonce; 104 105 /** 106 * Authorized party - the party to which the ID Token was issued. 107 * If present, it MUST contain the OAuth 2.0 Client ID of this party. 108 */ 109 @Nullable 110 public final String authorizedParty; 111 112 /** 113 * Additional claims present in this ID Token. 114 */ 115 @NonNull 116 public final Map<String, Object> additionalClaims; 117 118 @VisibleForTesting 119 IdToken(@NonNull String issuer, 120 @NonNull String subject, 121 @NonNull List<String> audience, 122 @NonNull Long expiration, 123 @NonNull Long issuedAt) { 124 this(issuer, subject, audience, expiration, issuedAt, null, null, Collections.emptyMap()); 125 } 126 127 @VisibleForTesting 128 IdToken(@NonNull String issuer, 129 @NonNull String subject, 130 @NonNull List<String> audience, 131 @NonNull Long expiration, 132 @NonNull Long issuedAt, 133 @Nullable String nonce, 134 @Nullable String authorizedParty) { 135 this(issuer, subject, audience, expiration, issuedAt, 136 nonce, authorizedParty, Collections.emptyMap()); 137 } 138 139 IdToken(@NonNull String issuer, 140 @NonNull String subject, 141 @NonNull List<String> audience, 142 @NonNull Long expiration, 143 @NonNull Long issuedAt, 144 @Nullable String nonce, 145 @Nullable String authorizedParty, 146 @NonNull Map<String, Object> additionalClaims) { 147 this.issuer = issuer; 148 this.subject = subject; 149 this.audience = audience; 150 this.expiration = expiration; 151 this.issuedAt = issuedAt; 152 this.nonce = nonce; 153 this.authorizedParty = authorizedParty; 154 this.additionalClaims = additionalClaims; 155 } 156 157 private static JSONObject parseJwtSection(String section) throws JSONException { 158 byte[] decodedSection = Base64.decode(section, Base64.URL_SAFE); 159 String jsonString = new String(decodedSection); 160 return new JSONObject(jsonString); 161 } 162 163 static IdToken from(String token) throws JSONException, IdTokenException { 164 String[] sections = token.split("\\."); 165 166 if (sections.length <= 1) { 167 throw new IdTokenException("ID token must have both header and claims section"); 168 } 169 170 // We ignore header contents, but parse it to check that it is structurally valid JSON 171 parseJwtSection(sections[0]); 172 JSONObject claims = parseJwtSection(sections[1]); 173 174 final String issuer = JsonUtil.getString(claims, KEY_ISSUER); 175 final String subject = JsonUtil.getString(claims, KEY_SUBJECT); 176 List<String> audience; 177 try { 178 audience = JsonUtil.getStringList(claims, KEY_AUDIENCE); 179 } catch (JSONException jsonEx) { 180 audience = new ArrayList<>(); 181 audience.add(JsonUtil.getString(claims, KEY_AUDIENCE)); 182 } 183 final Long expiration = claims.getLong(KEY_EXPIRATION); 184 final Long issuedAt = claims.getLong(KEY_ISSUED_AT); 185 final String nonce = JsonUtil.getStringIfDefined(claims, KEY_NONCE); 186 final String authorizedParty = JsonUtil.getStringIfDefined(claims, KEY_AUTHORIZED_PARTY); 187 188 for (String key: BUILT_IN_CLAIMS) { 189 claims.remove(key); 190 } 191 Map<String, Object> additionalClaims = JsonUtil.toMap(claims); 192 193 return new IdToken( 194 issuer, 195 subject, 196 audience, 197 expiration, 198 issuedAt, 199 nonce, 200 authorizedParty, 201 additionalClaims 202 ); 203 } 204 205 @VisibleForTesting 206 void validate(@NonNull TokenRequest tokenRequest, Clock clock) throws AuthorizationException { 207 validate(tokenRequest, clock, false); 208 } 209 210 void validate(@NonNull TokenRequest tokenRequest, 211 Clock clock, 212 boolean skipIssuerHttpsCheck) throws AuthorizationException { 213 // OpenID Connect Core Section 3.1.3.7. rule #1 214 // Not enforced: AppAuth does not support JWT encryption. 215 216 // OpenID Connect Core Section 3.1.3.7. rule #2 217 // Validates that the issuer in the ID Token matches that of the discovery document. 218 AuthorizationServiceDiscovery discoveryDoc = tokenRequest.configuration.discoveryDoc; 219 if (discoveryDoc != null) { 220 String expectedIssuer = discoveryDoc.getIssuer(); 221 if (!this.issuer.equals(expectedIssuer)) { 222 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 223 new IdTokenException("Issuer mismatch")); 224 } 225 226 // OpenID Connect Core Section 2. 227 // The iss value is a case sensitive URL using the https scheme that contains scheme, 228 // host, and optionally, port number and path components and no query or fragment 229 // components. 230 Uri issuerUri = Uri.parse(this.issuer); 231 232 if (!skipIssuerHttpsCheck && !issuerUri.getScheme().equals("https")) { 233 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 234 new IdTokenException("Issuer must be an https URL")); 235 } 236 237 if (TextUtils.isEmpty(issuerUri.getHost())) { 238 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 239 new IdTokenException("Issuer host can not be empty")); 240 } 241 242 if (issuerUri.getFragment() != null || issuerUri.getQueryParameterNames().size() > 0) { 243 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 244 new IdTokenException( 245 "Issuer URL should not containt query parameters or fragment components")); 246 } 247 } 248 249 250 // OpenID Connect Core Section 3.1.3.7. rule #3 & Section 2 azp Claim 251 // Validates that the aud (audience) Claim contains the client ID, or that the azp 252 // (authorized party) Claim matches the client ID. 253 String clientId = tokenRequest.clientId; 254 if (!this.audience.contains(clientId) && !clientId.equals(this.authorizedParty)) { 255 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 256 new IdTokenException("Audience mismatch")); 257 } 258 259 // OpenID Connect Core Section 3.1.3.7. rules #4 & #5 260 // Not enforced. 261 262 // OpenID Connect Core Section 3.1.3.7. rule #6 263 // As noted above, AppAuth only supports the code flow which results in direct 264 // communication of the ID Token from the Token Endpoint to the Client, and we are 265 // exercising the option to use TLS server validation instead of checking the token 266 // signature. Users may additionally check the token signature should they wish. 267 268 // OpenID Connect Core Section 3.1.3.7. rules #7 & #8 269 // Not enforced. See rule #6. 270 271 // OpenID Connect Core Section 3.1.3.7. rule #9 272 // Validates that the current time is before the expiry time. 273 Long nowInSeconds = clock.getCurrentTimeMillis() / MILLIS_PER_SECOND; 274 if (nowInSeconds > this.expiration) { 275 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 276 new IdTokenException("ID Token expired")); 277 } 278 279 // OpenID Connect Core Section 3.1.3.7. rule #10 280 // Validates that the issued at time is not more than +/- 10 minutes on the current 281 // time. 282 if (Math.abs(nowInSeconds - this.issuedAt) > TEN_MINUTES_IN_SECONDS) { 283 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 284 new IdTokenException("Issued at time is more than 10 minutes " 285 + "before or after the current time")); 286 } 287 288 // Only relevant for the authorization_code response type 289 if (GrantTypeValues.AUTHORIZATION_CODE.equals(tokenRequest.grantType)) { 290 // OpenID Connect Core Section 3.1.3.7. rule #11 291 // Validates the nonce. 292 String expectedNonce = tokenRequest.nonce; 293 if (!TextUtils.equals(this.nonce, expectedNonce)) { 294 throw AuthorizationException.fromTemplate(GeneralErrors.ID_TOKEN_VALIDATION_ERROR, 295 new IdTokenException("Nonce mismatch")); 296 } 297 } 298 // OpenID Connect Core Section 3.1.3.7. rules #12 299 // ACR is not directly supported by AppAuth. 300 301 // OpenID Connect Core Section 3.1.3.7. rules #13 302 // max_age is not directly supported by AppAuth. 303 } 304 305 static class IdTokenException extends Exception { 306 IdTokenException(String message) { 307 super(message); 308 } 309 } 310}