Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
This commit is contained in:
parent
d44a6935ef
commit
cff6bb444a
|
@ -0,0 +1,102 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995-2019 Mort Bay Consulting Pty. Ltd.
|
||||
// ------------------------------------------------------------------------
|
||||
// All rights reserved. This program and the accompanying materials
|
||||
// are made available under the terms of the Eclipse Public License v1.0
|
||||
// and Apache License v2.0 which accompanies this distribution.
|
||||
//
|
||||
// The Eclipse Public License is available at
|
||||
// http://www.eclipse.org/legal/epl-v10.html
|
||||
//
|
||||
// The Apache License v2.0 is available at
|
||||
// http://www.opensource.org/licenses/apache2.0.php
|
||||
//
|
||||
// You may elect to redistribute this code under either of these licenses.
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.openid;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jetty.util.ajax.JSON;
|
||||
import org.eclipse.jetty.util.log.Log;
|
||||
import org.eclipse.jetty.util.log.Logger;
|
||||
|
||||
/**
|
||||
* Used to decode the ID Token from the base64 encrypted JSON Web Token (JWT).
|
||||
*/
|
||||
public class JwtDecoder
|
||||
{
|
||||
private static final Logger LOG = Log.getLogger(JwtDecoder.class);
|
||||
|
||||
/**
|
||||
* Decodes a JSON Web Token (JWT) into a Map of claims.
|
||||
* @param jwt the JWT to decode.
|
||||
* @return the map of claims encoded in the JWT.
|
||||
*/
|
||||
public static Map<String, Object> decode(String jwt)
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("decode {}", jwt);
|
||||
|
||||
String[] sections = jwt.split("\\.");
|
||||
if (sections.length != 3)
|
||||
throw new IllegalArgumentException("JWT does not contain 3 sections");
|
||||
|
||||
Base64.Decoder decoder = Base64.getUrlDecoder();
|
||||
String jwtHeaderString = new String(decoder.decode(padJWTSection(sections[0])), StandardCharsets.UTF_8);
|
||||
String jwtClaimString = new String(decoder.decode(padJWTSection(sections[1])), StandardCharsets.UTF_8);
|
||||
String jwtSignature = sections[2];
|
||||
|
||||
Object parsedJwtHeader = JSON.parse(jwtHeaderString);
|
||||
if (!(parsedJwtHeader instanceof Map))
|
||||
throw new IllegalStateException("Invalid JWT header");
|
||||
Map<String, Object> jwtHeader = (Map)parsedJwtHeader;
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("JWT Header: {}", jwtHeader);
|
||||
|
||||
/* If the ID Token is received via direct communication between the Client
|
||||
and the Token Endpoint (which it is in this flow), the TLS server validation
|
||||
MAY be used to validate the issuer in place of checking the token signature. */
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("JWT signature not validated {}", jwtSignature);
|
||||
|
||||
Object parsedClaims = JSON.parse(jwtClaimString);
|
||||
if (!(parsedClaims instanceof Map))
|
||||
throw new IllegalStateException("Could not decode JSON for JWT claims.");
|
||||
return (Map)parsedClaims;
|
||||
}
|
||||
|
||||
static byte[] padJWTSection(String unpaddedEncodedJwtSection)
|
||||
{
|
||||
// If already padded just use what we are given.
|
||||
if (unpaddedEncodedJwtSection.endsWith("="))
|
||||
return unpaddedEncodedJwtSection.getBytes();
|
||||
|
||||
int length = unpaddedEncodedJwtSection.length();
|
||||
int remainder = length % 4;
|
||||
|
||||
// A valid base-64-encoded string will have a remainder of 0, 2 or 3. Never 1!
|
||||
if (remainder == 1)
|
||||
throw new IllegalArgumentException("Not a valid Base64-encoded string");
|
||||
|
||||
byte[] paddedEncodedJwtSection;
|
||||
if (remainder > 0)
|
||||
{
|
||||
int paddingNeeded = (4 - remainder) % 4;
|
||||
paddedEncodedJwtSection = Arrays.copyOf(unpaddedEncodedJwtSection.getBytes(), length + paddingNeeded);
|
||||
Arrays.fill(paddedEncodedJwtSection, length, paddedEncodedJwtSection.length, (byte)'=');
|
||||
}
|
||||
else
|
||||
{
|
||||
paddedEncodedJwtSection = unpaddedEncodedJwtSection.getBytes();
|
||||
}
|
||||
|
||||
return paddedEncodedJwtSection;
|
||||
}
|
||||
}
|
|
@ -18,11 +18,8 @@
|
|||
|
||||
package org.eclipse.jetty.security.openid;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
|
@ -103,7 +100,7 @@ public class OpenIdCredentials implements Serializable
|
|||
if (!"Bearer".equalsIgnoreCase(tokenType))
|
||||
throw new IllegalArgumentException("invalid token_type");
|
||||
|
||||
claims = decodeJWT(idToken);
|
||||
claims = JwtDecoder.decode(idToken);
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("claims {}", claims);
|
||||
validateClaims();
|
||||
|
@ -171,65 +168,6 @@ public class OpenIdCredentials implements Serializable
|
|||
return false;
|
||||
}
|
||||
|
||||
protected Map<String, Object> decodeJWT(String jwt) throws IOException
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("decodeJWT {}", jwt);
|
||||
|
||||
String[] sections = jwt.split("\\.");
|
||||
if (sections.length != 3)
|
||||
throw new IllegalArgumentException("JWT does not contain 3 sections");
|
||||
|
||||
Base64.Decoder decoder = Base64.getUrlDecoder();
|
||||
String jwtHeaderString = new String(decoder.decode(padJWTSection(sections[0])), StandardCharsets.UTF_8);
|
||||
String jwtClaimString = new String(decoder.decode(padJWTSection(sections[1])), StandardCharsets.UTF_8);
|
||||
String jwtSignature = sections[2];
|
||||
|
||||
Object parsedJwtHeader = JSON.parse(jwtHeaderString);
|
||||
if (!(parsedJwtHeader instanceof Map))
|
||||
throw new IllegalStateException("Invalid JWT header");
|
||||
Map<String, Object> jwtHeader = (Map)parsedJwtHeader;
|
||||
LOG.debug("JWT Header: {}", jwtHeader);
|
||||
|
||||
/* If the ID Token is received via direct communication between the Client
|
||||
and the Token Endpoint (which it is in this flow), the TLS server validation
|
||||
MAY be used to validate the issuer in place of checking the token signature. */
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("JWT signature not validated {}", jwtSignature);
|
||||
|
||||
Object parsedClaims = JSON.parse(jwtClaimString);
|
||||
if (!(parsedClaims instanceof Map))
|
||||
throw new IllegalStateException("Could not decode JSON for JWT claims.");
|
||||
|
||||
return (Map)parsedClaims;
|
||||
}
|
||||
|
||||
private static byte[] padJWTSection(String unpaddedEncodedJwtSection)
|
||||
{
|
||||
int length = unpaddedEncodedJwtSection.length();
|
||||
int remainder = length % 4;
|
||||
|
||||
if (remainder == 1)
|
||||
// A valid base64-encoded string will never be have an odd number of characters.
|
||||
throw new IllegalArgumentException("Not valid Base64-encoded string");
|
||||
|
||||
byte[] paddedEncodedJwtSection;
|
||||
|
||||
if (remainder > 0)
|
||||
{
|
||||
int paddingNeeded = (4 - remainder) % 4;
|
||||
|
||||
paddedEncodedJwtSection = Arrays.copyOf(unpaddedEncodedJwtSection.getBytes(), length + paddingNeeded);
|
||||
Arrays.fill(paddedEncodedJwtSection, length, paddedEncodedJwtSection.length, (byte)'=');
|
||||
}
|
||||
else
|
||||
{
|
||||
paddedEncodedJwtSection = unpaddedEncodedJwtSection.getBytes();
|
||||
}
|
||||
|
||||
return paddedEncodedJwtSection;
|
||||
}
|
||||
|
||||
private Map<String, Object> claimAuthCode(HttpClient httpClient, String authCode) throws Exception
|
||||
{
|
||||
Fields fields = new Fields();
|
||||
|
@ -250,7 +188,6 @@ public class OpenIdCredentials implements Serializable
|
|||
Object parsedResponse = JSON.parse(responseBody);
|
||||
if (!(parsedResponse instanceof Map))
|
||||
throw new IllegalStateException("Malformed response from OpenID Provider");
|
||||
|
||||
return (Map)parsedResponse;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995-2019 Mort Bay Consulting Pty. Ltd.
|
||||
// ------------------------------------------------------------------------
|
||||
// All rights reserved. This program and the accompanying materials
|
||||
// are made available under the terms of the Eclipse Public License v1.0
|
||||
// and Apache License v2.0 which accompanies this distribution.
|
||||
//
|
||||
// The Eclipse Public License is available at
|
||||
// http://www.eclipse.org/legal/epl-v10.html
|
||||
//
|
||||
// The Apache License v2.0 is available at
|
||||
// http://www.opensource.org/licenses/apache2.0.php
|
||||
//
|
||||
// You may elect to redistribute this code under either of these licenses.
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.openid;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
public class JwtDecoderTest
|
||||
{
|
||||
public static Stream<Arguments> paddingExamples()
|
||||
{
|
||||
return Stream.of(
|
||||
Arguments.of("XXXX", "XXXX"),
|
||||
Arguments.of("XXX", "XXX="),
|
||||
Arguments.of("XX", "XX=="),
|
||||
Arguments.of("XXX=", "XXX="),
|
||||
Arguments.of("X-X", "X-X="),
|
||||
Arguments.of("@#", "@#=="),
|
||||
Arguments.of("X=", "X="),
|
||||
Arguments.of("XX=", "XX="),
|
||||
Arguments.of("XX==", "XX=="),
|
||||
Arguments.of("XXX=", "XXX="),
|
||||
Arguments.of("", "")
|
||||
);
|
||||
}
|
||||
|
||||
public static Stream<Arguments> badPaddingExamples()
|
||||
{
|
||||
return Stream.of(
|
||||
Arguments.of("X"),
|
||||
Arguments.of("XXXXX")
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("paddingExamples")
|
||||
public void testPaddingBase64(String input, String expected)
|
||||
{
|
||||
byte[] actual = JwtDecoder.padJWTSection(input);
|
||||
assertThat(actual, is(expected.getBytes()));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("badPaddingExamples")
|
||||
public void testPaddingInvalidBase64(String input)
|
||||
{
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
|
||||
() -> JwtDecoder.padJWTSection(input));
|
||||
|
||||
assertThat(error.getMessage(), is("Not a valid Base64-encoded string"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEncodeDecode()
|
||||
{
|
||||
String issuer = "example.com";
|
||||
String subject = "1234";
|
||||
String clientId = "1234.client.id";
|
||||
String name = "Bob";
|
||||
long expiry = 123;
|
||||
|
||||
// Create a fake ID Token.
|
||||
String claims = JwtEncoder.createIdToken(issuer, clientId, subject, name, expiry);
|
||||
String idToken = JwtEncoder.encode(claims);
|
||||
|
||||
// Decode the ID Token and verify the claims are the same.
|
||||
Map<String, Object> decodedClaims = JwtDecoder.decode(idToken);
|
||||
assertThat(decodedClaims.get("iss"), is(issuer));
|
||||
assertThat(decodedClaims.get("sub"), is(subject));
|
||||
assertThat(decodedClaims.get("aud"), is(clientId));
|
||||
assertThat(decodedClaims.get("name"), is(name));
|
||||
assertThat(decodedClaims.get("exp"), is(expiry));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDecodeMissingPadding()
|
||||
{
|
||||
// Example given in Issue #4128 which requires the re-adding the B64 padding to decode.
|
||||
String jwt = "eyJraWQiOiIxNTU1OTM0ODQ3IiwieDV0IjoiOWdCOW9zRldSRHRSMkhtNGNmVnJnWTBGcmZRIiwiYWxnIjoiUlMyNTYifQ"
|
||||
+ ".eyJhdF9oYXNoIjoiQTA0NUoxcE5YRk1nYzlXN2wxSk1fUSIsImRlbGVnYXRpb25faWQiOiJjZTBhNjRlNS0xYWY3LTQ2MzEtOGUz"
|
||||
+ "NC1mNDE5N2JkYzVjZTAiLCJhY3IiOiJ1cm46c2U6Y3VyaXR5OmF1dGhlbnRpY2F0aW9uOmh0bWwtZm9ybTpodG1sLXByaW1hcnkiL"
|
||||
+ "CJzX2hhc2giOiIwc1FtRG9YY3FwcnM4NWUzdy0wbHdBIiwiYXpwIjoiNzZiZTc5Y2ItM2E1Ni00ZTE3LTg3NzYtNDI1Nzc5MjRjYz"
|
||||
+ "c2IiwiYXV0aF90aW1lIjoxNTY5NjU4MDk1LCJleHAiOjE1Njk2NjE5OTUsIm5iZiI6MTU2OTY1ODM5NSwianRpIjoiZjJkNWI2YzE"
|
||||
+ "tNTIxYi00Y2Y5LThlNWEtOTg5NGJhNmE0MzkyIiwiaXNzIjoiaHR0cHM6Ly9ub3JkaWNhcGlzLmN1cml0eS5pby9-IiwiYXVkIjoi"
|
||||
+ "NzZiZTc5Y2ItM2E1Ni00ZTE3LTg3NzYtNDI1Nzc5MjRjYzc2Iiwic3ViIjoibmlrb3MiLCJpYXQiOjE1Njk2NTgzOTUsInB1cnBvc"
|
||||
+ "2UiOiJpZCJ9.Wd458zNmXggpkDN6vbS3-aiajh4-VbkmcStLYUqahYJUp9p-AUI_RZttWvwh3UDMG9rWww_ya8KFK_SkPfKooEaSN"
|
||||
+ "OjOhw0ox4d-9lgti3J49eRyO20RViXvRHyLVtcjv5IaqvMXgwW60Thubv19OION7DstyArffcxNNSpiqDq6wjd0T2DJ3gSXXlJHLT"
|
||||
+ "Wrry3svqu1j_GCbHc04XYGicxsusKgc3n22dh4I6p4trdo0Gu5Un0bZ8Yov7IzWItqTgm9X5r9gZlAOLcAuK1WTwkzAwZJ24HgvxK"
|
||||
+ "muYfV_4ZCg_VPN2Op8YPuRAQOgUERpeTv1RDFTOG9GKZIMBVR0A";
|
||||
|
||||
// Decode the ID Token and verify the claims are the correct.
|
||||
Map<String, Object> decodedClaims = JwtDecoder.decode(jwt);
|
||||
assertThat(decodedClaims.get("sub"), is("nikos"));
|
||||
assertThat(decodedClaims.get("aud"), is("76be79cb-3a56-4e17-8776-42577924cc76"));
|
||||
assertThat(decodedClaims.get("exp"), is(1569661995L));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995-2019 Mort Bay Consulting Pty. Ltd.
|
||||
// ------------------------------------------------------------------------
|
||||
// All rights reserved. This program and the accompanying materials
|
||||
// are made available under the terms of the Eclipse Public License v1.0
|
||||
// and Apache License v2.0 which accompanies this distribution.
|
||||
//
|
||||
// The Eclipse Public License is available at
|
||||
// http://www.eclipse.org/legal/epl-v10.html
|
||||
//
|
||||
// The Apache License v2.0 is available at
|
||||
// http://www.opensource.org/licenses/apache2.0.php
|
||||
//
|
||||
// You may elect to redistribute this code under either of these licenses.
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.openid;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* A basic JWT encoder for testing purposes.
|
||||
*/
|
||||
public class JwtEncoder
|
||||
{
|
||||
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder();
|
||||
private static final String DEFAULT_HEADER = "{\"INFO\": \"this is not used or checked in our implementation\"}";
|
||||
private static final String DEFAULT_SIGNATURE = "we do not validate signature as we use the authorization code flow";
|
||||
|
||||
public static String encode(String idToken)
|
||||
{
|
||||
return stripPadding(ENCODER.encodeToString(DEFAULT_HEADER.getBytes())) + "." +
|
||||
stripPadding(ENCODER.encodeToString(idToken.getBytes())) + "." +
|
||||
stripPadding(ENCODER.encodeToString(DEFAULT_SIGNATURE.getBytes()));
|
||||
}
|
||||
|
||||
private static String stripPadding(String paddedBase64)
|
||||
{
|
||||
return paddedBase64.split("=")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic JWT for testing using argument supplied attributes.
|
||||
*/
|
||||
public static String createIdToken(String provider, String clientId, String subject, String name, long expiry)
|
||||
{
|
||||
return "{" +
|
||||
"\"iss\": \"" + provider + "\"," +
|
||||
"\"sub\": \"" + subject + "\"," +
|
||||
"\"aud\": \"" + clientId + "\"," +
|
||||
"\"exp\": " + expiry + "," +
|
||||
"\"name\": \"" + name + "\"," +
|
||||
"\"email\": \"" + name + "@example.com" + "\"" +
|
||||
"}";
|
||||
}
|
||||
}
|
|
@ -149,8 +149,8 @@ public class OpenIdAuthenticationTest
|
|||
content = response.getContentAsString().split("\n");
|
||||
assertThat(content.length, is(3));
|
||||
assertThat(content[0], is("userId: 123456789"));
|
||||
assertThat(content[1], is("name: FirstName LastName"));
|
||||
assertThat(content[2], is("email: FirstName@fake-email.com"));
|
||||
assertThat(content[1], is("name: Alice"));
|
||||
assertThat(content[2], is("email: Alice@example.com"));
|
||||
|
||||
// Request to admin page gives 403 as we do not have admin role
|
||||
response = client.GET(appUriString + "/admin");
|
||||
|
|
|
@ -22,7 +22,6 @@ import java.io.IOException;
|
|||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
@ -137,7 +136,7 @@ public class OpenIdProvider extends ContainerLifeCycle
|
|||
}
|
||||
|
||||
String authCode = UUID.randomUUID().toString().replace("-", "");
|
||||
User user = new User(123456789, "FirstName", "LastName");
|
||||
User user = new User(123456789, "Alice");
|
||||
issuedAuthCodes.put(authCode, user);
|
||||
|
||||
final Request baseRequest = Request.getBaseRequest(req);
|
||||
|
@ -173,20 +172,11 @@ public class OpenIdProvider extends ContainerLifeCycle
|
|||
return;
|
||||
}
|
||||
|
||||
String jwtHeader = "{\"INFO\": \"this is not used or checked in our implementation\"}";
|
||||
String jwtBody = user.getIdToken();
|
||||
String jwtSignature = "we do not validate signature as we use the authorization code flow";
|
||||
|
||||
Base64.Encoder encoder = Base64.getEncoder();
|
||||
String jwt = encoder.encodeToString(jwtHeader.getBytes()) + "." +
|
||||
encoder.encodeToString(jwtBody.getBytes()) + "." +
|
||||
encoder.encodeToString(jwtSignature.getBytes());
|
||||
|
||||
String accessToken = "ABCDEFG";
|
||||
long expiry = System.currentTimeMillis() + Duration.ofMinutes(10).toMillis();
|
||||
String response = "{" +
|
||||
"\"access_token\": \"" + accessToken + "\"," +
|
||||
"\"id_token\": \"" + jwt + "\"," +
|
||||
"\"id_token\": \"" + JwtEncoder.encode(user.getIdToken()) + "\"," +
|
||||
"\"expires_in\": " + expiry + "," +
|
||||
"\"token_type\": \"Bearer\"" +
|
||||
"}";
|
||||
|
@ -214,41 +204,28 @@ public class OpenIdProvider extends ContainerLifeCycle
|
|||
public class User
|
||||
{
|
||||
private long subject;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String name;
|
||||
|
||||
public User(String firstName, String lastName)
|
||||
public User(String name)
|
||||
{
|
||||
this(new Random().nextLong(), firstName, lastName);
|
||||
this(new Random().nextLong(), name);
|
||||
}
|
||||
|
||||
public User(long subject, String firstName, String lastName)
|
||||
public User(long subject, String name)
|
||||
{
|
||||
this.subject = subject;
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getFirstName()
|
||||
public String getName()
|
||||
{
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public String getLastName()
|
||||
{
|
||||
return lastName;
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getIdToken()
|
||||
{
|
||||
return "{" +
|
||||
"\"iss\": \"" + provider + "\"," +
|
||||
"\"sub\": \"" + subject + "\"," +
|
||||
"\"aud\": \"" + clientId + "\"," +
|
||||
"\"exp\": " + System.currentTimeMillis() + Duration.ofMinutes(1).toMillis() + "," +
|
||||
"\"name\": \"" + firstName + " " + lastName + "\"," +
|
||||
"\"email\": \"" + firstName + "@fake-email.com" + "\"" +
|
||||
"}";
|
||||
long expiry = System.currentTimeMillis() + Duration.ofMinutes(1).toMillis();
|
||||
return JwtEncoder.createIdToken(provider, clientId, Long.toString(subject), name, expiry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue