blob: f073178cd35a6ac7e23b5dad1abbb1effaffdd38 [file] [log] [blame]
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////////
package com.google.crypto.tink.jwt;
import com.google.errorprone.annotations.Immutable;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
/** A set of expected claims and headers to validate against another JWT. */
@Immutable
public final class JwtValidator {
private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(10);
private final Optional<String> issuer;
private final Optional<String> subject;
private final Optional<String> audience;
@SuppressWarnings("Immutable") // We do not mutate the clock.
private final Clock clock;
private final Duration clockSkew;
private JwtValidator(Builder builder) {
this.issuer = builder.issuer;
this.subject = builder.subject;
this.audience = builder.audience;
this.clock = builder.clock;
this.clockSkew = builder.clockSkew;
}
/** Builder for JwtValidator */
public static final class Builder {
private Optional<String> issuer;
private Optional<String> subject;
private Optional<String> audience;
private Clock clock = Clock.systemUTC();
private Duration clockSkew = Duration.ZERO;
public Builder() {
this.issuer = Optional.empty();
this.subject = Optional.empty();
this.audience = Optional.empty();
}
/**
* Sets the expected issuer of the token. When this is set, all tokens with missing or different
* {@code iss} claims are rejected.
*
* <p>https://tools.ietf.org/html/rfc7519#section-4.1.1
*/
public Builder setIssuer(String value) {
if (value == null) {
throw new NullPointerException("issuer cannot be null");
}
this.issuer = Optional.of(value);
return this;
}
/**
* Sets the expected subject of the token. When this is set, all tokens with missing or
* different {@code sub} claims are rejected.
* <p>https://tools.ietf.org/html/rfc7519#section-4.1.2
*/
public Builder setSubject(String value) {
if (value == null) {
throw new NullPointerException("subject cannot be null");
}
this.subject = Optional.of(value);
return this;
}
/**
* Sets the expected audience. When this is set, all tokens that do not contain this audience
* in their {@code aud} claims are rejected. If is not set, all token that have {@code aud}
* claims are rejected. So this must be set for token that have {@code aud} claims.
*
* <p>https://tools.ietf.org/html/rfc7519#section-4.1.3
*/
public Builder setAudience(String value) {
if (value == null) {
throw new NullPointerException("audience cannot be null");
}
this.audience = Optional.of(value);
return this;
}
/** Sets the clock used to verify timestamp claims. */
public Builder setClock(java.time.Clock clock) {
if (clock == null) {
throw new NullPointerException("clock cannot be null");
}
this.clock = clock;
return this;
}
/**
* Sets the clock skew to tolerate when verifying timestamp claims, to deal with small clock
* differences among different machines.
*
* <p>As recommended by https://tools.ietf.org/html/rfc7519, the clock skew should usually be no
* more than a few minutes. In this implementation, the maximum value is 10 minutes.
*/
public Builder setClockSkew(Duration clockSkew) {
if (clockSkew.compareTo(MAX_CLOCK_SKEW) > 0) {
throw new IllegalArgumentException("Clock skew too large, max is 10 minutes");
}
this.clockSkew = clockSkew;
return this;
}
public JwtValidator build() {
return new JwtValidator(this);
}
}
/**
* Validates that all claims in this validator are also present in {@code target}.
* @throws JwtInvalidException when {@code target} contains an invalid claim or header
*/
VerifiedJwt validate(RawJwt target) throws JwtInvalidException {
validateTimestampClaims(target);
if (this.issuer.isPresent()) {
if (!target.hasIssuer()) {
throw new JwtInvalidException(
String.format("invalid JWT; missing expected issuer %s.", this.issuer.get()));
}
if (!target.getIssuer().equals(this.issuer.get())) {
throw new JwtInvalidException(
String.format(
"invalid JWT; expected issuer %s, but got %s", this.issuer.get(), issuer));
}
}
if (this.subject.isPresent()) {
if (!target.hasSubject()) {
throw new JwtInvalidException(
String.format("invalid JWT; missing expected subject %s.", this.subject.get()));
}
if (!target.getSubject().equals(this.subject.get())) {
throw new JwtInvalidException(
String.format(
"invalid JWT; expected subject %s, but got %s", this.subject.get(), subject));
}
}
if (this.audience.isPresent()) {
if (!target.hasAudiences() || !target.getAudiences().contains(this.audience.get())) {
throw new JwtInvalidException(
String.format("invalid JWT; missing expected audience %s.", this.audience.get()));
}
} else {
if (target.hasAudiences()) {
throw new JwtInvalidException("invalid JWT; token has audience set, but validator not.");
}
}
return new VerifiedJwt(target);
}
private void validateTimestampClaims(RawJwt target) throws JwtInvalidException {
Instant now = this.clock.instant();
// If expiration = now.minus(clockSkew), then the token is expired.
if (target.hasExpiration() && !target.getExpiration().isAfter(now.minus(this.clockSkew))) {
throw new JwtInvalidException("token has expired since " + target.getExpiration());
}
// If not_before = now.plus(clockSkew), then the token is fine.
if (target.hasNotBefore() && target.getNotBefore().isAfter(now.plus(this.clockSkew))) {
throw new JwtInvalidException("token cannot be used before " + target.getNotBefore());
}
}
}