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}