Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve OIDC compliance #265

Merged
merged 12 commits into from
Dec 5, 2019
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.auth0.android.provider;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Base64;

import com.auth0.android.jwt.JWT;

import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;

/**
* Token signature verifier for HS256 algorithms.
*/
class AsymmetricSignatureVerifier extends SignatureVerifier {

private Signature publicSignature;

/**
* Creates a new instance of the verifier
*
* @param publicKey the public key to use for verification
* @throws InvalidKeyException if the public key provided is null or not of type RSA
*/
AsymmetricSignatureVerifier(@Nullable PublicKey publicKey) throws InvalidKeyException {
try {
publicSignature = Signature.getInstance("SHA256withRSA");
publicSignature.initVerify(publicKey);
} catch (NoSuchAlgorithmException ignored) {
//Safe to ignore: "SHA256withRSA" is available since API 1
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
//https://developer.android.com/reference/java/security/Signature.html
}
}

@Override
void verifySignature(@NonNull JWT token) throws TokenValidationException {
String[] parts = token.toString().split("\\.");
String content = parts[0] + "." + parts[1];
byte[] contentBytes = content.getBytes(Charset.defaultCharset());
byte[] signatureBytes = Base64.decode(parts[2], Base64.URL_SAFE | Base64.NO_WRAP);
boolean valid = false;
try {
publicSignature.update(contentBytes);
valid = publicSignature.verify(signatureBytes);
} catch (Exception ignored) {
//safe to ignore: throws when the Signature object is not properly initialized
}
if (!valid) {
throw new TokenValidationException("Invalid ID token signature.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.auth0.android.provider;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import java.util.Date;

class IdTokenVerificationOptions {
private final String issuer;
private final String audience;
private final SignatureVerifier verifier;
private String nonce;
private Integer maxAge;
private Integer clockSkew;
private Date clock;

IdTokenVerificationOptions(@NonNull String issuer, @NonNull String audience, @NonNull SignatureVerifier verifier) {
this.issuer = issuer;
this.audience = audience;
this.verifier = verifier;
}

void setNonce(@Nullable String nonce) {
this.nonce = nonce;
}

void setMaxAge(@Nullable Integer maxAge) {
this.maxAge = maxAge;
}

void setClockSkew(@Nullable Integer clockSkew) {
this.clockSkew = clockSkew;
}

void setClock(@Nullable Date now) {
this.clock = now;
}

@NonNull
String getIssuer() {
return issuer;
}

@NonNull
String getAudience() {
return audience;
}

@NonNull
SignatureVerifier getSignatureVerifier() {
return verifier;
}

@Nullable
String getNonce() {
return nonce;
}

@Nullable
Integer getMaxAge() {
return maxAge;
}

@Nullable
Integer getClockSkew() {
return clockSkew;
}

@Nullable
Date getClock() {
return clock;
}
}
117 changes: 117 additions & 0 deletions auth0/src/main/java/com/auth0/android/provider/IdTokenVerifier.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.auth0.android.provider;

import android.support.annotation.NonNull;

import com.auth0.android.jwt.JWT;

import java.util.Calendar;
import java.util.Date;
import java.util.List;

import static android.text.TextUtils.isEmpty;

class IdTokenVerifier {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class file was taken from mvc-commons implementation. Tests to be added on a separate PR

private static final Integer DEFAULT_CLOCK_SKEW = 60; //1 min = 60 sec

private static final String NONCE_CLAIM = "nonce";
private static final String AZP_CLAIM = "azp";
private static final String AUTH_TIME_CLAIM = "auth_time";

/**
* Verifies a provided ID Token follows the OIDC specification.
* See https://openid.net/specs/openid-connect-core-1_0-final.html#IDTokenValidation
*
* @param token the ID Token to verify.
* @param verifyOptions the verification options, like audience, issuer, algorithm.
* @throws TokenValidationException If the ID Token is null, its signing algorithm not supported, its signature invalid or one of its claim invalid.
*/
void verify(@NonNull JWT token, @NonNull IdTokenVerificationOptions verifyOptions) throws TokenValidationException {
verifyOptions.getSignatureVerifier().verifySignature(token);

if (isEmpty(token.getIssuer())) {
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
throw new TokenValidationException("Issuer (iss) claim must be a string present in the ID token");
}
//noinspection ConstantConditions
if (!token.getIssuer().equals(verifyOptions.getIssuer())) {
throw new TokenValidationException(String.format("Issuer (iss) claim mismatch in the ID token, expected \"%s\", found \"%s\"", verifyOptions.getIssuer(), token.getIssuer()));
}

if (isEmpty(token.getSubject())) {
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
throw new TokenValidationException("Subject (sub) claim must be a string present in the ID token");
}

final List<String> audience = token.getAudience();
if (audience == null) {
throw new TokenValidationException("Audience (aud) claim must be a string or array of strings present in the ID token");
}
if (!audience.contains(verifyOptions.getAudience())) {
throw new TokenValidationException(String.format("Audience (aud) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", verifyOptions.getAudience(), token.getAudience()));
}

final Calendar cal = Calendar.getInstance();
final Date now = verifyOptions.getClock() != null ? verifyOptions.getClock() : cal.getTime();
final int clockSkew = verifyOptions.getClockSkew() != null ? verifyOptions.getClockSkew() : DEFAULT_CLOCK_SKEW;

if (token.getExpiresAt() == null) {
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
throw new TokenValidationException("Expiration Time (exp) claim must be a number present in the ID token");
}

cal.setTime(token.getExpiresAt());
cal.add(Calendar.SECOND, clockSkew);
Date expDate = cal.getTime();

if (now.after(expDate)) {
throw new TokenValidationException(String.format("Expiration Time (exp) claim error in the ID token; current time (%d) is after expiration time (%d)", now.getTime() / 1000, expDate.getTime() / 1000));
}

if (token.getIssuedAt() == null) {
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
throw new TokenValidationException("Issued At (iat) claim must be a number present in the ID token");
}

cal.setTime(token.getIssuedAt());
cal.add(Calendar.SECOND, -1 * clockSkew);
Date iatDate = cal.getTime();

if (now.before(iatDate)) {
throw new TokenValidationException(String.format("Issued At (iat) claim error in the ID token; current time (%d) is before issued at time (%d)", now.getTime() / 1000, iatDate.getTime() / 1000));
}


if (verifyOptions.getNonce() != null) {
String nonceClaim = token.getClaim(NONCE_CLAIM).asString();
if (isEmpty(nonceClaim)) {
throw new TokenValidationException("Nonce (nonce) claim must be a string present in the ID token");
}
if (!verifyOptions.getNonce().equals(nonceClaim)) {
throw new TokenValidationException(String.format("Nonce (nonce) claim mismatch in the ID token; expected \"%s\", found \"%s\"", verifyOptions.getNonce(), nonceClaim));
}
}

if (audience.size() > 1) {
String azpClaim = token.getClaim(AZP_CLAIM).asString();
if (isEmpty(azpClaim)) {
throw new TokenValidationException("Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values");
}
if (!verifyOptions.getAudience().equals(azpClaim)) {
throw new TokenValidationException(String.format("Authorized Party (azp) claim mismatch in the ID token; expected \"%s\", found \"%s\"", verifyOptions.getAudience(), azpClaim));
}
}

if (verifyOptions.getMaxAge() != null) {
Date authTime = token.getClaim(AUTH_TIME_CLAIM).asDate();
if (authTime == null) {
throw new TokenValidationException("Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified");
}

cal.setTime(authTime);
cal.add(Calendar.SECOND, verifyOptions.getMaxAge());
cal.add(Calendar.SECOND, clockSkew);
Date authTimeDate = cal.getTime();

if (now.after(authTimeDate)) {
throw new TokenValidationException(String.format("Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (%d) is after last auth at (%d)", now.getTime() / 1000, authTimeDate.getTime() / 1000));
}
}
}

}
Loading