diff --git a/changes.md b/changes.md index 393076b2d0..5b71bb0072 100644 --- a/changes.md +++ b/changes.md @@ -5,11 +5,14 @@ Changes log - Misc - Added an optional origin matcher to CORS (`CorsService`, `CorsFilter`, `setAllowedOriginMatcher(Predicate)`) to accept origins that are not listed, for example any sub-domain. The response always contains the exact request origin; it has no effect while the allowed origins contain `*`. Issue #1611. - CorsService and CorsFilter now log a warning when `*` is allowed as origin together with credentials (any web site could then send credentialed requests), and their Javadoc references the Fetch standard instead of the W3C CORS draft. Issue #1611. + - Added an opt-in UTF-8 handling of the HTTP Basic credentials (RFC 7617), enabled with the `org.restlet.engine.security.basic.utf8` system property (`HttpBasicHelper.UTF8_PROPERTY`): a Basic challenge then advertises `charset="UTF-8"`, a server decodes the credentials as UTF-8 (falling back to ISO-8859-1 if they are not valid UTF-8), and a client answering such a challenge encodes its credentials in UTF-8. Disabled by default (ISO-8859-1). Issue #1455. - Bugs fixed - Missing throw in JsonConverter.java. Issue #729. - Type mismatch on container access in MethodAnnotationInfo. Issue #1278. - Boolean canAppend in ZipClientHelper can only be false. Issue #1279. - CorsService and CorsFilter now add `Vary: Origin` to every response when the CORS answer depends on the request origin (origin allow-list or credentials allowed), including requests without an `Origin` header, so shared caches no longer serve the response of one origin to another. A static `*` without credentials still sends no `Vary`, as recommended by the Fetch standard. Issue #1611. + - Fixed the parsing of a `WWW-Authenticate` challenge made of a token68 ending with `=` (such as a `Negotiate` token): the whole header was dropped. Issue #191. + - The `realm` and `charset` parameters of an HTTP Basic challenge are now recognized whatever their case (`Realm=`, `Charset=`), as required by RFC 9110. Issue #1455. - 2.6.1 (30-09-2026) - Bugs fixed diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java index 4a90958189..02918378c7 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java @@ -55,6 +55,13 @@ public HeaderWriter append(Parameter value) { } }; + // A challenge may carry a single token68 instead of parameters + // (RFC 9110, section 11.2), such as a base64 Negotiate token. + String token68 = readToken68(); + if (token68 != null) { + w.append(token68); + } + boolean stop = false; while (peek() != -1 && !stop) { boolean sepSkipped = skipValueSeparator(); @@ -83,4 +90,41 @@ public HeaderWriter append(Parameter value) { return result; } + + /** + * Reads a token68 ending with at least one "=" padding character, if the next + * characters are one. A token68 without padding is read as a regular parameter, + * as before. + * + * @return The token68, or null (nothing consumed) if the next characters are + * not one. + */ + private String readToken68() { + mark(); + StringBuilder token = new StringBuilder(); + int next = peek(); + while (next != -1 && isToken68Char(next)) { + token.append((char) read()); + next = peek(); + } + + int padding = 0; + while (next == '=') { + token.append((char) read()); + padding++; + next = peek(); + } + + if (token.length() > padding && padding > 0 && (next == -1 || next == ',' || isSpace(next))) { + return token.toString(); + } + + reset(); + return null; + } + + private static boolean isToken68Char(int c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' + || c == '_' || c == '~' || c == '+' || c == '/'; + } } diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index df08c74e88..5501d46795 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -33,6 +33,9 @@ */ public class AuthenticatorUtils { + /** Name of the challenge parameter that advertises the credentials charset. */ + private static final String CHARSET_PARAMETER = "charset"; + /** * Indicates if any of the objects is null. * @@ -56,40 +59,42 @@ public static boolean anyNull(Object... objects) { * @return The {@link HeaderConstants#HEADER_AUTHENTICATION_INFO} header value. */ public static String formatAuthenticationInfo(AuthenticationInfo info) { - ChallengeWriter cw = new ChallengeWriter(); - boolean firstParameter = true; - - if (info != null) { - if (info.getNextServerNonce() != null && !info.getNextServerNonce().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendQuotedChallengeParameter("nextnonce", info.getNextServerNonce()); - firstParameter = false; - } - - if (info.getQuality() != null && !info.getQuality().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendChallengeParameter("qop", info.getQuality()); - firstParameter = false; + final ChallengeWriter cw = new ChallengeWriter(); - if (info.getNonceCount() > 0) { - cw.appendChallengeParameter("nc", formatNonceCount(info.getNonceCount())); - } - } - - if (info.getResponseDigest() != null && !info.getResponseDigest().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendQuotedChallengeParameter("rspauth", info.getResponseDigest()); - firstParameter = false; - } - - if (info.getClientNonce() != null && !info.getClientNonce().isEmpty()) { - cw.setFirstChallengeParameter(firstParameter); - cw.appendChallengeParameter("cnonce", info.getClientNonce()); - firstParameter = false; - } + if (info == null) { + return cw.toString(); } - return cw.toString(); + boolean firstParameter = true; + if (info.getNextServerNonce() != null && !info.getNextServerNonce().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendQuotedChallengeParameter("nextnonce", info.getNextServerNonce()); + firstParameter = false; + } + + if (info.getQuality() != null && !info.getQuality().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendChallengeParameter("qop", info.getQuality()); + firstParameter = false; + + if (info.getNonceCount() > 0) { + cw.appendChallengeParameter("nc", formatNonceCount(info.getNonceCount())); + } + } + + if (info.getResponseDigest() != null && !info.getResponseDigest().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendQuotedChallengeParameter("rspauth", info.getResponseDigest()); + firstParameter = false; + } + + if (info.getClientNonce() != null && !info.getClientNonce().isEmpty()) { + cw.setFirstChallengeParameter(firstParameter); + cw.appendChallengeParameter("cnonce", info.getClientNonce()); + firstParameter = false; + } + + return cw.toString(); } /** @@ -130,33 +135,42 @@ public static String formatRequest(ChallengeRequest challenge, Response response } else if (challenge.getScheme().getTechnicalName() == null) { Context.getCurrentLogger().warning("A challenge scheme must have a technical name defined."); } else { - ChallengeWriter cw = new ChallengeWriter(); - cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); - int cwInitialLength = cw.getBuffer().length(); + result = doFormatRequest(challenge, response, httpHeaders); + } + + return result; + } - if (challenge.getRawValue() != null) { - cw.append(challenge.getRawValue()); + private static String doFormatRequest(ChallengeRequest challenge, Response response, Series
httpHeaders) { + ChallengeWriter cw = new ChallengeWriter(); + cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); + int cwInitialLength = cw.getBuffer().length(); + + if (challenge.getRawValue() == null) { + AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), false, true); + + if (helper == null) { + logUnsupportedChallengeScheme(challenge.getScheme()); } else { - AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), false, true); - - if (helper != null) { - try { - helper.formatRequest(cw, challenge, response, httpHeaders); - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, - "Unable to format the challenge request: " + challenge, e); - } - } else { - result = "?"; - Context.getCurrentLogger().warning( - "Challenge scheme " + challenge.getScheme() + " not supported by the Restlet engine."); + try { + helper.formatRequest(cw, challenge, response, httpHeaders); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, e, + () -> "Unable to format the challenge request: " + challenge); } } - - result = (cw.getBuffer().length() > cwInitialLength) ? cw.toString() : null; + } else { + cw.append(challenge.getRawValue()); } - return result; + return (cw.getBuffer().length() > cwInitialLength) + ? cw.toString() + : null; + } + + private static void logUnsupportedChallengeScheme(ChallengeScheme challengeScheme) { + Context.getCurrentLogger().log(Level.WARNING, + () -> "Challenge scheme " + challengeScheme + " not supported by the Restlet engine."); } /** @@ -182,34 +196,39 @@ public static String formatResponse(ChallengeResponse challenge, Request request } else if (challenge.getScheme().getTechnicalName() == null) { Context.getCurrentLogger().warning("A challenge scheme must have a technical name defined."); } else { - ChallengeWriter cw = new ChallengeWriter(); - cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); - int cwInitialLength = cw.getBuffer().length(); - - if (challenge.getRawValue() != null) { - cw.append(challenge.getRawValue()); - } else { - AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), true, false); - - if (helper != null) { - try { - helper.formatResponse(cw, challenge, request, httpHeaders); - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, - "Unable to format the challenge response: " + challenge, e); - } - } else { - Context.getCurrentLogger().warning( - "Challenge scheme " + challenge.getScheme() + " not supported by the Restlet engine."); - } - } - - result = (cw.getBuffer().length() > cwInitialLength) ? cw.toString() : null; + result = doFormatResponse(challenge, request, httpHeaders); } return result; } + private static String doFormatResponse(ChallengeResponse challenge, Request request, Series
httpHeaders) { + ChallengeWriter cw = new ChallengeWriter(); + cw.append(challenge.getScheme().getTechnicalName()).appendSpace(); + int cwInitialLength = cw.getBuffer().length(); + + if (challenge.getRawValue() == null) { + AuthenticatorHelper helper = Engine.getInstance().findHelper(challenge.getScheme(), true, false); + + if (helper == null) { + logUnsupportedChallengeScheme(challenge.getScheme()); + } else { + try { + helper.formatResponse(cw, challenge, request, httpHeaders); + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, e, + () -> "Unable to format the challenge response: " + challenge); + } + } + } else { + cw.append(challenge.getRawValue()); + } + + return (cw.getBuffer().length() > cwInitialLength) + ? cw.toString() + : null; + } + /** * Parses the "Authentication-Info" header. * @@ -218,7 +237,7 @@ public static String formatResponse(ChallengeResponse challenge, Request request */ public static AuthenticationInfo parseAuthenticationInfo(String header) { AuthenticationInfo result = null; - HeaderReader hr = new HeaderReader(header); + HeaderReader hr = new HeaderReader<>(header); try { String nextNonce = null; @@ -255,8 +274,8 @@ public static AuthenticationInfo parseAuthenticationInfo(String header) { result = new AuthenticationInfo(nextNonce, nonceCount, cnonce, qop, responseAuth); } catch (IOException e) { - Context.getCurrentLogger().log(Level.WARNING, "Unable to parse the authentication info header: " + header, - e); + Context.getCurrentLogger().log(Level.WARNING, e, + () -> "Unable to parse the authentication info header: " + header); } return result; @@ -271,22 +290,20 @@ public static AuthenticationInfo parseAuthenticationInfo(String header) { * @return The list of parsed challenge request. */ public static List parseRequest(Response response, String header, Series
httpHeaders) { - List result = new ArrayList(); + List result = new ArrayList<>(); if (header != null) { result = new ChallengeRequestReader(header).readValues(); for (ChallengeRequest cr : result) { - // Give a chance to the authenticator helper to do further - // parsing + // Give a chance to the authenticator helper to do further parsing AuthenticatorHelper helper = Engine.getInstance().findHelper(cr.getScheme(), true, false); - if (helper != null) { - helper.parseRequest(cr, response, httpHeaders); - } else { - Context.getCurrentLogger() - .warning("Couldn't find any helper support the " + cr.getScheme() + " challenge scheme."); - } - } + if (helper == null) { + logUnsupportedChallengeScheme(cr.getScheme()); + } else { + helper.parseRequest(cr, response, httpHeaders); + } + } } return result; @@ -320,13 +337,12 @@ public static ChallengeResponse parseResponse(Request request, String header, Se // Give a chance to the authenticator helper to do further parsing AuthenticatorHelper helper = Engine.getInstance().findHelper(result.getScheme(), true, false); - if (helper != null) { - helper.parseResponse(result, request, httpHeaders); - } else { - Context.getCurrentLogger() - .warning("Couldn't find any helper support the " + result.getScheme() + " challenge scheme."); - } - } + if (helper == null) { + logUnsupportedChallengeScheme(result.getScheme()); + } else { + helper.parseResponse(result, request, httpHeaders); + } + } return result; @@ -357,6 +373,13 @@ public static void update(ChallengeResponse challengeResponse, Request request, realm = challengeRequest.getRealm(); nonce = challengeRequest.getServerNonce(); challengeResponse.setOpaque(challengeRequest.getOpaque()); + + // Credentials charset advertised by the server (RFC 7617, section 2.1) + String charset = challengeRequest.getParameters().getFirstValue(CHARSET_PARAMETER, true); + if (HttpBasicHelper.isUtf8Enabled() && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) + && charset != null && challengeResponse.getParameters().getFirstValue(CHARSET_PARAMETER, true) == null) { + challengeResponse.getParameters().add(CHARSET_PARAMETER, charset); + } } challengeResponse.setRealm(realm); @@ -384,8 +407,7 @@ public static Reference updateReference(Reference resourceRef, ChallengeResponse if (helper != null) { resourceRef = helper.updateReference(resourceRef, challengeResponse, request); } else { - Context.getCurrentLogger().warning( - "Challenge scheme " + challengeResponse.getScheme() + " not supported by the Restlet engine."); + logUnsupportedChallengeScheme(challengeResponse.getScheme()); } } diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 4a884e54ca..ba9b52146e 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -21,8 +21,11 @@ import java.io.CharArrayWriter; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Objects; import java.util.logging.Level; /** @@ -32,6 +35,49 @@ */ public class HttpBasicHelper extends AuthenticatorHelper { + /** + * Name of the system property that enables the UTF-8 handling of the HTTP Basic + * credentials (RFC 7617, section 2.1). Disabled by default, to keep the + * historical ISO-8859-1 behavior. When set to "true": a server challenge + * advertises {@code charset="UTF-8"}, credentials received by a server are + * decoded as UTF-8 (falling back to ISO-8859-1 if they are not valid UTF-8) and + * a client answering a challenge that advertises UTF-8 encodes its credentials + * in UTF-8. + */ + public static final String UTF8_PROPERTY = "org.restlet.engine.security.basic.utf8"; + + private static final String CHARSET = "charset"; + + private static final String UTF_8 = "UTF-8"; + + private static final String ISO_8859_1 = "ISO-8859-1"; + + /** + * Indicates if the UTF-8 handling of the HTTP Basic credentials is enabled. + * + * @return True if the {@link #UTF8_PROPERTY} system property is "true". + * @see #UTF8_PROPERTY + */ + public static boolean isUtf8Enabled() { + return Boolean.getBoolean(UTF8_PROPERTY); + } + + /** + * Decodes the credentials sent by a client. The bytes are read as UTF-8 first + * and, if they are not valid UTF-8, as ISO-8859-1, the historical default. + * + * @param bytes The decoded Base64 credentials. + * @return The "user-id:password" string. + */ + private static String decodeUtf8First(byte[] bytes) { + try { + return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + return new String(bytes, StandardCharsets.ISO_8859_1); + } + } + /** * Constructor. */ @@ -43,7 +89,7 @@ public HttpBasicHelper() { public void formatRequest(ChallengeWriter cw, ChallengeRequest challenge, Response response, Series
httpHeaders) throws IOException { String realm = challenge.getRealm(); - String charset = challenge.getParameters().getFirstValue("charset"); + String charset = challenge.getParameters().getFirstValue(CHARSET, true); if (realm != null) { cw.appendQuotedChallengeParameter("realm", realm); @@ -53,8 +99,8 @@ public void formatRequest(ChallengeWriter cw, ChallengeRequest challenge, Respon } if (charset != null) { - if ("UTF-8".equalsIgnoreCase(charset)) { - cw.appendQuotedChallengeParameter("charset", "UTF-8"); + if (UTF_8.equalsIgnoreCase(charset)) { + cw.appendQuotedChallengeParameter(CHARSET, UTF_8); } else { getLogger().warning("The \"charset\" parameter must be \"UTF-8\" per RFC 7617."); } @@ -68,18 +114,18 @@ public void formatResponse(ChallengeWriter cw, ChallengeResponse challenge, Requ if (challenge == null) { throw new RuntimeException("No challenge provided, unable to encode credentials"); } else { - String charset = challenge.getParameters().getFirstValue("charset"); + String charset = challenge.getParameters().getFirstValue(CHARSET, true); if (charset != null) { - if ("UTF-8".equalsIgnoreCase(charset)) { - charset = "UTF-8"; + if (UTF_8.equalsIgnoreCase(charset)) { + charset = UTF_8; } else { getLogger().warning( "The \"charset\" parameter must be \"UTF-8\" per RFC 7617. Using \"ISO-8859-1\" instead."); - charset = "ISO-8859-1"; + charset = ISO_8859_1; } } else { - charset = "ISO-8859-1"; + charset = ISO_8859_1; } CharArrayWriter credentials = new CharArrayWriter(); @@ -97,36 +143,38 @@ public void formatResponse(ChallengeWriter cw, ChallengeResponse challenge, Requ @Override public void parseRequest(ChallengeRequest challenge, Response response, Series
httpHeaders) { - if (challenge.getRawValue() != null) { - HeaderReader hr = new HeaderReader(challenge.getRawValue()); - - try { - Parameter param = hr.readParameter(); - - while (param != null) { - try { - if ("realm".equals(param.getName())) { - challenge.setRealm(param.getValue()); - } else { - challenge.getParameters().add(param); - } - - if (hr.skipValueSeparator()) { - param = hr.readParameter(); - } else { - param = null; - } - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, - "Unable to parse the challenge request header parameter", e); - } - } - } catch (Exception e) { - Context.getCurrentLogger().log(Level.WARNING, "Unable to parse the challenge request header parameter", - e); - } - } - } + if (challenge.getRawValue() == null) { + return; + } + + HeaderReader hr = new HeaderReader<>(challenge.getRawValue()); + + try { + Parameter param = hr.readParameter(); + + while (param != null) { + try { + if ("realm".equalsIgnoreCase(param.getName())) { + challenge.setRealm(param.getValue()); + } else { + challenge.getParameters().add(param); + } + + if (hr.skipValueSeparator()) { + param = hr.readParameter(); + } else { + param = null; + } + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, + "Unable to parse the challenge request header parameter", e); + } + } + } catch (Exception e) { + Context.getCurrentLogger().log(Level.WARNING, "Unable to parse the challenge request header parameter", + e); + } + } @Override public void parseResponse(ChallengeResponse challenge, Request request, Series
httpHeaders) { @@ -136,28 +184,29 @@ public void parseResponse(ChallengeResponse challenge, Request request, Series "Invalid credentials given by client with IP: " + ((request != null) ? request.getClientInfo().getAddress() : "?")); } else { challenge.setIdentifier(credentials.substring(0, separator)); diff --git a/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java b/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java index 2fedc570e6..6051fde1c4 100644 --- a/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java +++ b/org.restlet.java/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java @@ -13,6 +13,7 @@ import org.restlet.Request; import org.restlet.Response; import org.restlet.data.*; +import org.restlet.engine.security.HttpBasicHelper; import java.util.logging.Level; @@ -111,76 +112,97 @@ protected boolean authenticate(Request request, Response response) { boolean result = false; boolean loggable = request.isLoggable() && getLogger().isLoggable(Level.FINE); - if (getVerifier() != null) { - switch (getVerifier().verify(request, response)) { - case Verifier.RESULT_VALID: - // Valid credentials provided - result = true; - - if (loggable) { - ChallengeResponse challengeResponse = request.getChallengeResponse(); - - if (challengeResponse != null) { - getLogger().fine("Authentication succeeded. Valid credentials provided for identifier: " - + request.getChallengeResponse().getIdentifier() + "."); - } else { - getLogger().fine("Authentication succeeded. Valid credentials provided."); - } - } + if (getVerifier() == null) { + getLogger().warning("Authentication failed. No verifier provided."); + response.setStatus(Status.SERVER_ERROR_INTERNAL, "Authentication failed. No verifier provided."); + } else { + switch (getVerifier().verify(request, response)) { + case Verifier.RESULT_VALID: + // Valid credentials provided + result = validCredentials(request, loggable); break; - case Verifier.RESULT_MISSING: - // No credentials provided - if (loggable) { - getLogger().fine("Authentication failed. No credentials provided."); - } - - if (!isOptional()) { - challenge(response, false); - } + case Verifier.RESULT_MISSING: + // No credentials provided + noCredentialsProvided(response, loggable); break; - case Verifier.RESULT_INVALID: - // Invalid credentials provided - if (loggable) { - getLogger().fine("Authentication failed. Invalid credentials provided."); - } - - if (!isOptional()) { - if (isRechallenging()) { - challenge(response, false); - } else { - forbid(response); - } - } + case Verifier.RESULT_INVALID: + // Invalid credentials provided + invalidCredentialsProvided(response, loggable); break; - case Verifier.RESULT_STALE: - if (loggable) { - getLogger().fine("Authentication failed. Stale credentials provided."); - } - - if (!isOptional()) { - challenge(response, true); - } + case Verifier.RESULT_STALE: + staleAuthentication(response, loggable); break; - case Verifier.RESULT_UNKNOWN: - if (loggable) { - getLogger().fine("Authentication failed. Identifier is unknown."); - } - - if (!isOptional()) { - if (isRechallenging()) { - challenge(response, false); - } else { - forbid(response); - } - } + case Verifier.RESULT_UNKNOWN: + unknownIdentifier(response, loggable); break; + default: + throw new IllegalStateException("Unexpected value: " + getVerifier().verify(request, response)); +} + } + + return result; + } + + private void unknownIdentifier(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. Identifier is unknown."); + } + + if (!isOptional()) { + if (isRechallenging()) { + challenge(response, false); + } else { + forbid(response); } - } else { - getLogger().warning("Authentication failed. No verifier provided."); - response.setStatus(Status.SERVER_ERROR_INTERNAL, "Authentication failed. No verifier provided."); } + } - return result; + private void staleAuthentication(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. Stale credentials provided."); + } + + if (!isOptional()) { + challenge(response, true); + } + } + + private void invalidCredentialsProvided(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. Invalid credentials provided."); + } + + if (!isOptional()) { + if (isRechallenging()) { + challenge(response, false); + } else { + forbid(response); + } + } + } + + private void noCredentialsProvided(Response response, boolean loggable) { + if (loggable) { + getLogger().fine("Authentication failed. No credentials provided."); + } + + if (!isOptional()) { + challenge(response, false); + } + } + + private boolean validCredentials(Request request, boolean loggable) { + if (loggable) { + ChallengeResponse challengeResponse = request.getChallengeResponse(); + + if (challengeResponse != null) { + getLogger().fine("Authentication succeeded. Valid credentials provided for identifier: " + + request.getChallengeResponse().getIdentifier() + "."); + } else { + getLogger().fine("Authentication succeeded. Valid credentials provided."); + } + } + return true; } /** @@ -208,7 +230,14 @@ public void challenge(Response response, boolean stale) { * @return A new challenge request. */ protected ChallengeRequest createChallengeRequest(boolean stale) { - return new ChallengeRequest(getScheme(), getRealm()); + ChallengeRequest result = new ChallengeRequest(getScheme(), getRealm()); + + if (ChallengeScheme.HTTP_BASIC.equals(getScheme()) && HttpBasicHelper.isUtf8Enabled()) { + // RFC 7617 section 2.1: credentials are expected in UTF-8 + result.getParameters().add("charset", "UTF-8"); + } + + return result; } /** @@ -224,7 +253,7 @@ public void forbid(Response response) { if (loggable) { getLogger().log(Level.FINE, - "Authentication or authorization failed for this URI: " + response.getRequest().getResourceRef()); + () -> "Authentication or authorization failed for this URI: " + response.getRequest().getResourceRef()); } response.setStatus(Status.CLIENT_ERROR_FORBIDDEN); diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java new file mode 100644 index 0000000000..1ffe7d2d8a --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java @@ -0,0 +1,82 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.header; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.restlet.data.ChallengeRequest; + +class ChallengeRequestReaderTestCase { + + @Test + void readValue_schemeOnly_parsesSchemeName() throws IOException { + ChallengeRequestReader reader = new ChallengeRequestReader("Basic"); + ChallengeRequest result = reader.readValue(); + assertEquals("Basic", result.getScheme().getTechnicalName()); + } + + @Test + void readValue_schemeWithParameters_capturesRawValue() throws IOException { + ChallengeRequestReader reader = new ChallengeRequestReader("Digest realm=\"testrealm\""); + ChallengeRequest result = reader.readValue(); + assertEquals("Digest", result.getScheme().getTechnicalName()); + assertTrue(result.getRawValue().contains("realm")); + } + + @Test + void readValue_emptyHeader_returnsNull() throws IOException { + ChallengeRequestReader reader = new ChallengeRequestReader(""); + assertNull(reader.readValue()); + } + + @Test + void readValues_twoChallenges_returnsBoth() { + List result = + new ChallengeRequestReader("Basic realm=\"a\", Digest realm=\"b\", nonce=\"x\"") + .readValues(); + assertEquals(2, result.size()); + assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + assertEquals("realm=a", result.get(0).getRawValue()); + assertEquals("Digest", result.get(1).getScheme().getTechnicalName()); + assertEquals("realm=b, nonce=x", result.get(1).getRawValue()); + } + + @Test + void readValues_schemeWithoutParametersThenChallenge_returnsBoth() { + List result = + new ChallengeRequestReader("Negotiate, Basic realm=\"a\"").readValues(); + assertEquals(2, result.size()); + assertEquals("Negotiate", result.get(0).getScheme().getTechnicalName()); + assertEquals("Basic", result.get(1).getScheme().getTechnicalName()); + } + + @Test + void readValues_commaInsideQuotedValue_staysInOneChallenge() { + List result = + new ChallengeRequestReader("Basic realm=\"a, Digest b\"").readValues(); + assertEquals(1, result.size()); + assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + } + + /** RFC 9110 section 11.2: token68 may end with "=" padding, as Negotiate tokens do. */ + @Test + void readValues_token68Challenge_keepsToken() { + List result = + new ChallengeRequestReader("Negotiate YIIabc==, Basic realm=\"a\"").readValues(); + assertEquals(2, result.size()); + assertEquals("Negotiate", result.get(0).getScheme().getTechnicalName()); + assertTrue(result.get(0).getRawValue().contains("YIIabc")); + assertEquals("Basic", result.get(1).getScheme().getTechnicalName()); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java new file mode 100644 index 0000000000..be2c9e9dd5 --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsParseTestCase.java @@ -0,0 +1,40 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Method; +import org.restlet.engine.Engine; + +/** Unit tests for the parsing of WWW-Authenticate headers in {@link AuthenticatorUtils}. */ +class AuthenticatorUtilsParseTestCase { + + @Test + void parseRequest_twoChallenges_eachGetsItsSchemeAndRealm() { + Engine.register(); // other tests may leave an engine without the Basic helper + List result = + AuthenticatorUtils.parseRequest( + new Response(new Request(Method.GET, "http://localhost/")), + "Basic realm=\"my realm\", Bearer error=\"x\"", + null); + + assertEquals(2, result.size()); + assertEquals(ChallengeScheme.HTTP_BASIC, result.get(0).getScheme()); + assertEquals("my realm", result.get(0).getRealm()); + assertEquals("Bearer", result.get(1).getScheme().getTechnicalName()); + assertEquals("error=x", result.get(1).getRawValue()); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java new file mode 100644 index 0000000000..49dc5122fe --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -0,0 +1,210 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Method; +import org.restlet.data.Parameter; +import org.restlet.engine.Engine; + +/** + * Unit tests for the credentials charset handling of {@link HttpBasicHelper} (RFC 7617 section + * 2.1), without any network. + */ +class HttpBasicHelperTestCase { + + @BeforeEach + void enableUtf8() { + Engine.register(); // other tests may leave an engine without the Basic helper + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + } + + @AfterEach + void restoreDefault() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + } + + private static final String LATIN1_LOGIN = "josé"; + private static final String LATIN1_PASSWORD = "pässwörd"; + private static final String UTF8_LOGIN = "zoë"; + + /** The euro sign does not exist in ISO-8859-1. */ + private static final String UTF8_PASSWORD = "p€ss"; + + private static String basicHeader(String login, String password, java.nio.charset.Charset cs) { + byte[] bytes = (login + ":" + password).getBytes(cs); + return "Basic " + Base64.getEncoder().encodeToString(bytes); + } + + private static String base64Of(String login, String password, java.nio.charset.Charset cs) { + return Base64.getEncoder().encodeToString((login + ":" + password).getBytes(cs)); + } + + // ---- Server side: decoding the Authorization header -------------------------------------- + + @Test + void parseResponse_asciiCredentials_decoded() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader("user", "secret", StandardCharsets.UTF_8), + null); + assertEquals("user", cr.getIdentifier()); + assertEquals("secret", String.valueOf(cr.getSecret())); + } + + /** Backward compatibility: bytes that are not valid UTF-8 are still read as ISO-8859-1. */ + @Test + void parseResponse_latin1Bytes_decodedAsIso88591() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1), + null); + assertEquals(LATIN1_LOGIN, cr.getIdentifier()); + assertEquals(LATIN1_PASSWORD, String.valueOf(cr.getSecret())); + } + + /** Issue #1455: the server advertises UTF-8 but decodes ISO-8859-1. */ + @Test + void parseResponse_utf8Bytes_decodedAsUtf8() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + null); + assertEquals(UTF8_LOGIN, cr.getIdentifier()); + assertEquals(UTF8_PASSWORD, String.valueOf(cr.getSecret())); + } + + /** The default is unchanged: without the opt-in, credentials are read as ISO-8859-1. */ + @Test + void parseResponse_utf8Bytes_utf8Disabled_decodedAsIso88591() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader("zoë", "pw", StandardCharsets.UTF_8), + null); + assertEquals("zoë", cr.getIdentifier()); + } + + @Test + void formatResponse_challengeAdvertisedUtf8_utf8Disabled_encodesIso88591() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + response.getChallengeRequests().add(challenge); + + ChallengeResponse cr = + new ChallengeResponse(challenge, response, "josé", "pässwörd".toCharArray()); + + assertEquals( + "Basic " + base64Of("josé", "pässwörd", StandardCharsets.ISO_8859_1), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } + + // ---- Server side: advertising the charset in the challenge ------------------------------- + + @Test + void formatRequest_charsetParameter_advertisesUtf8() { + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + + assertEquals( + "Basic realm=\"realm\", charset=\"UTF-8\"", + AuthenticatorUtils.formatRequest(challenge, new Response(null), null)); + } + + @Test + void formatRequest_noCharsetParameter_realmOnly() { + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + + assertEquals( + "Basic realm=\"realm\"", + AuthenticatorUtils.formatRequest(challenge, new Response(null), null)); + } + + // ---- Client side: encoding the Authorization header -------------------------------------- + + @Test + void formatResponse_default_encodesIso88591() { + ChallengeResponse cr = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, LATIN1_LOGIN, LATIN1_PASSWORD); + + assertEquals( + "Basic " + base64Of(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1), + AuthenticatorUtils.formatResponse( + cr, new Request(Method.GET, "http://localhost/"), null)); + } + + @Test + void formatResponse_charsetParameter_encodesUtf8() { + ChallengeResponse cr = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_LOGIN, UTF8_PASSWORD); + cr.getParameters().add(new Parameter("charset", "UTF-8")); + + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse( + cr, new Request(Method.GET, "http://localhost/"), null)); + } + + /** + * Issue #1455: a client that answers a challenge advertising {@code charset="UTF-8"} should + * encode its credentials in UTF-8 without any manual parameter. + */ + @Test + void formatResponse_challengeAdvertisedUtf8_encodesUtf8() { + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + response.getChallengeRequests().add(challenge); + + ChallengeResponse cr = + new ChallengeResponse(challenge, response, UTF8_LOGIN, UTF8_PASSWORD.toCharArray()); + + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } + + // ---- Case insensitivity of the parameter names (RFC 9110 section 11.2) ----------------------- + + @Test + void parseRequest_parameterNamesInAnyCase_realmAndCharsetRecognized() { + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = + AuthenticatorUtils.parseRequest( + response, "Basic REALM=\"r\", Charset=\"utf-8\"", null) + .get(0); + + assertEquals("r", challenge.getRealm()); + assertEquals("utf-8", challenge.getParameters().getFirstValue("charset", true)); + + response.getChallengeRequests().add(challenge); + ChallengeResponse cr = + new ChallengeResponse(challenge, response, UTF8_LOGIN, UTF8_PASSWORD.toCharArray()); + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java new file mode 100644 index 0000000000..c7ec71d68e --- /dev/null +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorCharsetTestCase.java @@ -0,0 +1,56 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeScheme; +import org.restlet.engine.security.HttpBasicHelper; + +/** Unit tests for the credentials charset advertised by {@link ChallengeAuthenticator}. */ +class ChallengeAuthenticatorCharsetTestCase { + + @AfterEach + void restoreDefault() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + } + + /** Issue #1455: a Basic challenge advertises UTF-8 credentials when enabled. */ + @Test + void createChallengeRequest_basic_advertisesUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeRequest challenge = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm") + .createChallengeRequest(false); + assertEquals("realm", challenge.getRealm()); + assertEquals("UTF-8", challenge.getParameters().getFirstValue("charset")); + } + + /** The default is unchanged: without the opt-in, no charset is advertised. */ + @Test + void createChallengeRequest_basic_utf8Disabled_hasNoCharsetParameter() { + ChallengeRequest challenge = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm") + .createChallengeRequest(false); + assertNull(challenge.getParameters().getFirstValue("charset")); + } + + @Test + void createChallengeRequest_digest_hasNoCharsetParameter() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeRequest challenge = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_DIGEST, "realm") + .createChallengeRequest(false); + assertNull(challenge.getParameters().getFirstValue("charset")); + } +} diff --git a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java index 328c2ab542..aa906141eb 100644 --- a/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java +++ b/org.restlet.java/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java @@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.params.provider.Arguments.arguments; +import java.io.IOException; import java.util.Arrays; import java.util.stream.Stream; @@ -35,6 +36,7 @@ import org.restlet.data.Method; import org.restlet.data.Protocol; import org.restlet.data.Status; +import org.restlet.engine.security.HttpBasicHelper; /** * Restlet unit tests for HTTP Basic authentication client/server. @@ -44,6 +46,12 @@ */ public class HttpBasicTestCase { + public static final String LATIN1_USERNAME = "jos\u00e9"; + public static final String LATIN1_PASSWORD = "p\u00e4ssw\u00f6rd"; + /** The euro sign does not exist in ISO-8859-1. */ + public static final String UTF8_USERNAME = "zo\u00eb"; + public static final String UTF8_PASSWORD = "p\u20acss"; + public static class AuthenticatedRestlet extends Restlet { @Override public void handle(Request request, Response response) { @@ -55,18 +63,16 @@ public static class TestVerifier extends MapVerifier { public TestVerifier() { getLocalSecrets().put(SHORT_USERNAME, SHORT_PASSWORD.toCharArray()); getLocalSecrets().put(LONG_USERNAME, LONG_PASSWORD.toCharArray()); + getLocalSecrets().put(LATIN1_USERNAME, LATIN1_PASSWORD.toCharArray()); + getLocalSecrets().put(UTF8_USERNAME, UTF8_PASSWORD.toCharArray()); } @Override public int verify(String identifier, char[] inputSecret) { - // NOTE: Allocating Strings are not really secure treatment of passwords - String almostSecret = new String(inputSecret); - try { return super.verify(identifier, inputSecret); } finally { - // Clear secret from memory as soon as possible (This is better - // treatment, but useless due to our almostSecret copy) + // Clear secret from memory as soon as possible Arrays.fill(inputSecret, '\000'); } } @@ -126,14 +132,14 @@ class TestHttpBasicServer { private Client client; @Test - public void HttpBasicNone() throws Exception { + void HttpBasicNone() { final Response response = client.handle(request); assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); } @ParameterizedTest @MethodSource("org.restlet.security.HttpBasicTestCase#invalidCredentials") - void testInvalidCredentials(final String login, final String password) throws Exception { + void testInvalidCredentials(final String login, final String password) { ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, login, password); request.setChallengeResponse(authentication); @@ -143,7 +149,7 @@ void testInvalidCredentials(final String login, final String password) throws Ex @ParameterizedTest @MethodSource("org.restlet.security.HttpBasicTestCase#validCredentials") - void testValidCredentials(final String login, final String password) throws Exception { + void testValidCredentials(final String login, final String password) throws IOException { ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, login, password); request.setChallengeResponse(authentication); @@ -152,8 +158,38 @@ void testValidCredentials(final String login, final String password) throws Exce assertEquals(AUTHENTICATED_MSG, response.getEntity().getText()); } + /** Clients that predate RFC 7617 and send ISO-8859-1 must keep working. */ + @Test + void testLatin1CredentialsWithoutCharset() { + request.setChallengeResponse( + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, LATIN1_USERNAME, LATIN1_PASSWORD)); + + assertEquals(Status.SUCCESS_OK, client.handle(request).getStatus()); + } + + /** Issue #1455: credentials sent as UTF-8 are accepted by the server. */ + @Test + void testUtf8CredentialsWithCharset() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeResponse authentication = new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_USERNAME, + UTF8_PASSWORD); + authentication.getParameters().add("charset", "UTF-8"); + request.setChallengeResponse(authentication); + + assertEquals(Status.SUCCESS_OK, client.handle(request).getStatus()); + } + + /** Issue #1455: the server challenge carries charset="UTF-8" when enabled. */ + @Test + void testChallengeAdvertisesUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + Response response = client.handle(request); + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + assertEquals("UTF-8", response.getChallengeRequests().get(0).getParameters().getFirstValue("charset")); + } + @BeforeEach - public void makeServer() throws Exception { + void makeServer() throws Exception { final String REALM = HttpBasicTestCase.class.getSimpleName(); this.component = new Component(); final Server server = this.component.getServers().add(Protocol.HTTP, 0); @@ -176,7 +212,8 @@ public Restlet createInboundRoot() { } @AfterEach - public void cleanup() throws Exception { + void cleanup() throws Exception { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); client.stop(); if (this.component.isStarted()) { this.component.stop();