diff --git a/sdk/clientcore/optional-dependency-tests/pom.xml b/sdk/clientcore/optional-dependency-tests/pom.xml index 00acb3e5820c..4b241bec4626 100644 --- a/sdk/clientcore/optional-dependency-tests/pom.xml +++ b/sdk/clientcore/optional-dependency-tests/pom.xml @@ -15,7 +15,7 @@ io.clientcore optional-dependency-tests jar - 1.0.0-beta.1 + 1.0.0-beta.2 Java Core library tests for optional dependencies and features. Tests that validate optional dependencies and features of the Core library. @@ -56,14 +56,14 @@ io.clientcore core - 1.0.0-beta.2 + 1.0.0-beta.2 io.clientcore core - 1.0.0-beta.2 + 1.0.0-beta.2 test-jar test @@ -88,7 +88,7 @@ io.opentelemetry opentelemetry-sdk-extension-autoconfigure - 1.43.0 + 1.43.0 test diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/AuthenticateChallengeParser.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/AuthenticateChallengeParser.java new file mode 100644 index 000000000000..4cb03cb5851f --- /dev/null +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/AuthenticateChallengeParser.java @@ -0,0 +1,529 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.core.implementation.http; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.util.AuthenticateChallenge; +import com.azure.core.util.logging.ClientLogger; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * Parses a {@link HttpHeaderName#WWW_AUTHENTICATE} or {@link HttpHeaderName#PROXY_AUTHENTICATE} header value into the + * pieces required to create one or more {@link AuthenticateChallenge}. + */ +public final class AuthenticateChallengeParser { + private static final ClientLogger LOGGER = new ClientLogger(AuthenticateChallengeParser.class); + + private static final boolean[] VALID_TOKEN_CHARS = new boolean[128]; + private static final boolean[] VALID_TOKEN68_CHARS = new boolean[128]; + + static { + // Setup VALID_TOKEN68_CHARS first as it is mostly a subset of VALID_TCHARS. + // The only exception is that token68 allows '/', set that after copying. + // This is also excluding the '=' character as that is only allowed at the end of a token68 and will be handled + // externally to this lookup table. + Arrays.fill(VALID_TOKEN68_CHARS, '0', '9' + 1, true); + Arrays.fill(VALID_TOKEN68_CHARS, 'A', 'Z' + 1, true); + Arrays.fill(VALID_TOKEN68_CHARS, 'a', 'z' + 1, true); + VALID_TOKEN68_CHARS['-'] = true; + VALID_TOKEN68_CHARS['.'] = true; + VALID_TOKEN68_CHARS['_'] = true; + VALID_TOKEN68_CHARS['~'] = true; + VALID_TOKEN68_CHARS['+'] = true; + + System.arraycopy(VALID_TOKEN68_CHARS, 0, VALID_TOKEN_CHARS, 0, 128); + VALID_TOKEN_CHARS['!'] = true; + VALID_TOKEN_CHARS['#'] = true; + VALID_TOKEN_CHARS['$'] = true; + VALID_TOKEN_CHARS['%'] = true; + VALID_TOKEN_CHARS['&'] = true; + VALID_TOKEN_CHARS['\''] = true; + VALID_TOKEN_CHARS['*'] = true; + VALID_TOKEN_CHARS['^'] = true; + VALID_TOKEN_CHARS['`'] = true; + VALID_TOKEN_CHARS['|'] = true; + + VALID_TOKEN68_CHARS['/'] = true; + } + + private final String challenge; + private final int challengeLength; + + private State state = State.BEGINNING; + private int currentIndex; + private AuthenticateChallengeToken token; + + /** + * Creates an instance of AuthenticateChallengeParser. + * + * @param challenge The challenge to parse. + */ + public AuthenticateChallengeParser(String challenge) { + this.challenge = Objects.requireNonNull(challenge, "challenge cannot be null."); + this.challengeLength = challenge.length(); + this.currentIndex = 0; + + // WWW-Authenticate and Proxy-Authenticate use the form: + // + // *( "," OWS ) challenge *( OWS "," [ OWS challenge ] ) + // + // Which means the header may begin with any number of ',', SP (U+0020 / ' '), and HTAB (U+0009 / '\t') + // characters. Skip those characters. + while (currentIndex < challengeLength) { + char currentCharacter = challenge.charAt(currentIndex); + if (currentCharacter != ',' && currentCharacter != ' ' && currentCharacter != '\t') { + break; + } + + currentIndex++; + } + } + + /** + * Parses the authenticate header into a list of {@link AuthenticateChallenge}. + * + * @return A list of {@link AuthenticateChallenge}. + * @throws IllegalArgumentException If the authenticate header is malformed. + */ + public List parse() { + // At a high-level the authenticate headers take the form of: + // WWW-Authenticate: , , ... + // Proxy-Authenticate: , , ... + // + // At a more technical level, which this method will parse the format (using ABNF) is: + // + // authenticate-header = 1#challenge (at lease one challenge, delimited by ',' and optional spaces) + // challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] (may contain a token68 or 0 or more auth-params) + // auth-scheme = token + // auth-param = token BWS "=" BWS ( token / quoted-string ) + // token68 = 1*( ALPHA / DIGIT / - / . / _ / ~ / + / '/' ) *"=" + // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + // qdtext = HTAB / SP / ! / '#' - '[' / ']' - '~' / obs-text + // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + // obs-text = U+0080 - U+00FF (extended ASCII) + // token = 1*( tchar ) + // tchar = ALPHA / DIGIT / ! / # / $ / % / & / ' / * / + / - / . / ^ / _ / ` / | / ~ + // VCHAR = U+0021 - U+007E (! - ~, or all printable ASCII characters except space and delete) + // + // BWS is optional spaces (SP / HTAB) that exists for historical reasons and should be handled during parsing + // but must not be generated. + // + // All information above is taken from RFC 7230 and RFC 7235. + // https://www.rfc-editor.org/rfc/rfc7230 + // https://www.rfc-editor.org/rfc/rfc7235 + // + // WWW-Authenticate and Proxy-Authenticate use the form: + // + // *( "," OWS ) challenge *( OWS "," [ OWS challenge ] ) + // + // Which means the header may begin with any number of ',', SP (U+0020 / ' '), and HTAB (U+0009 / '\t') + // characters. Skip those characters. + // + // Then replacing 'challenge' with its definition it becomes: + // + // *( "," OWS ) auth-scheme [ 1*SP ( token68 / #auth-param ) ] + // *( OWS "," [ OWS auth-scheme [ 1*SP ( token68 / #auth-param ) ] ] ) + // + // Where the auth-scheme and token68 / auth-params are separated by SP characters only. The logic for parsing + // will be the following: + // + // 1. Skip any leading spaces and commas. + // 2. Split the authenticate header into chunks delimited by commas that aren't within a quoted string. + // 3. Remove any leading or trailing OWS (optional spaces) from each chunk. + // 4. Process each chunk, keeping track of the current state of the parser, using the following logic: + // + // I. If it's the first chunk being processed it must contain a scheme. Optionally, that chunk may also include + // a token68 or an auth-param after the scheme separated by SP characters. If the first chunk isn't a scheme, + // an IllegalArgumentException will be thrown. + // II. Subsequent chunks will use the following logic: + // i. If the chunk contains unquoted SP characters separating token characters (or roughly the equivalent for + // valid token68 characters), then the chunk is a new challenge scheme. + // ii. If the chunk contains equal signs, then the chunk is either a token68 or an auth-param. Determine + // which one based on where the equal signs are, if the equal signs are the trailing characters of the + // chunk it's a token68 otherwise it's an auth-param. + // III. If the chunk is a token68 or an auth-param, then add it to the current challenge. + // i. If the current challenge already contains a token68 then any subsequent token68 or auth-params will + // throw an IllegalArgumentException. + // IV. Once a new challenge scheme is found, the previous challenge is added to the list and state is reset. + List authenticateChallenges = new ArrayList<>(); + + String scheme = null; + String token68 = null; + Map parameters = null; + + while (next()) { + if (token.scheme != null) { + // This piece contained a scheme. + // This is either the first scheme or a new scheme, handle it appropriately. + if (scheme != null) { + // This is a new scheme, add the previous challenge to the list. + authenticateChallenges.add(createChallenge(scheme, token68, parameters)); + parameters = null; + token68 = null; + } + + scheme = token.scheme; + } else if (token.token68 != null) { + if (scheme == null) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException("Challenge had token68 before scheme.")); + } else if (token68 != null) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException("Challenge had multiple token68s.")); + } + + token68 = token.token68; + } else if (token.authParam != null) { + if (scheme == null) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException("Challenge had auth-param before scheme.")); + } + + if (parameters == null) { + parameters = new LinkedHashMap<>(); + } + + if (parameters.put(token.authParam.getKey(), token.authParam.getValue()) != null) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException("Challenge had duplicate auth-param.")); + } + } + } + + if (scheme != null) { + authenticateChallenges.add(createChallenge(scheme, token68, parameters)); + } + + return authenticateChallenges; + } + + private AuthenticateChallenge createChallenge(String scheme, String token68, Map parameters) { + if (token68 == null && parameters == null) { + return new AuthenticateChallenge(scheme); + } else if (token68 == null) { + return new AuthenticateChallenge(scheme, parameters); + } else if (parameters == null) { + return new AuthenticateChallenge(scheme, token68); + } + + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException("Challenge had both token68 and auth-params.")); + } + + boolean next() { + if (currentIndex >= challengeLength) { + return false; + } + + if (state == State.BEGINNING) { + handleBeginning(); + } else if (state == State.SCHEME) { + handleScheme(); + } else if (state == State.CHALLENGE_SEPARATOR) { + handleChallenge(); + } + + return true; + } + + private char iterateUntil(Predicate until) { + while (currentIndex < challengeLength) { + char c = challenge.charAt(currentIndex); + if (until.test(c)) { + return c; + } + + currentIndex++; + } + + return '\0'; + } + + private char iterateUntilNextNonSpace() { + currentIndex++; + return iterateUntil(c -> c != ' ' && c != '\t'); + } + + private char iterateUntilEqualsSpaceOrComma() { + return iterateUntil(c -> c == '=' || c == ' ' || c == ','); + } + + private void handleBeginning() { + // If the state is BEGINNING, then the next token must be a scheme. + // Beginning state is the state before any characters have been processed (except leading spaces and commas). + // The only valid characters in this case are one or more token characters followed by one of spaces or comma. + int start = currentIndex; + char c = iterateUntil(c1 -> c1 == ' ' || c1 == '\t' || c1 == ','); + + token = handleSchemeToken(start, currentIndex, c, false); + } + + private AuthenticateChallengeToken handleSchemeToken(int schemeStartInclusive, int schemeEndExclusive, + char currentChar, boolean alreadyInNextState) { + String scheme = challenge.substring(schemeStartInclusive, schemeEndExclusive); + if (!isValidToken(scheme)) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .addKeyValue("scheme", scheme) + .log(new IllegalArgumentException("Scheme contained an invalid character.")); + } + + // Iterate until the next non-space character, unless the scheme terminated with a comma. + currentChar = (alreadyInNextState || currentChar == ',') ? currentChar : iterateUntilNextNonSpace(); + if (currentIndex < challengeLength && currentChar == ',') { + // The next character is a comma, update the state to CHALLENGE_SEPARATOR and continue. + // This is important as SCHEME indicates that the next token is either a token68 or an auth-param. + // CHALLENGE_SEPARATOR indicates that the next token can be anything. + state = State.CHALLENGE_SEPARATOR; + iterateUntilNextNonSpace(); // Iterate to the first character in the challenge piece. + } else { + state = State.SCHEME; + } + + return new AuthenticateChallengeToken(scheme, null, null); + } + + private void handleScheme() { + // If the state is SCHEME, then the next token must be a token68 or an auth-param. + // Search until a comma, space, or equal sign is found. + // If a comma is found or the end of challenge reached the current token is a token68 as auth-param requires an + // equal sign whereas token68 those are optional padding. + // If a space is found, the current token could be either an auth-param or a token68. Iterate until the next + // non-space is found or the end of challenge is reached. If a comma is found or end of challenge reached then + // it's a token68, if an equal sign is found then it's an auth-param, if anything else is found it's an error. + // If an equal sign is found, check if there are equal signs until a space, comma, or end of challenge, then + // it's a token68, otherwise it's an auth-param. + int start = currentIndex; // This start will never have leading space. + char c = iterateUntilEqualsSpaceOrComma(); + + if (c == ',' || currentIndex == challengeLength) { + // As stated above, must be a token68. + token = new AuthenticateChallengeToken(null, validateToken68(challenge, start, currentIndex), null); + } else if (c == ' ') { + int token68OrParamKeyEnd = currentIndex; + c = iterateUntilNextNonSpace(); + if (c != '=' && c != ',' && currentIndex < challengeLength) { + // The next character is neither a comma nor an equal sign, throw an exception. + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException( + "Challenge had more than one token68 or auth-param in the same comma separator.")); + } + + if (c == ',' || currentIndex == challengeLength) { + String token68 = validateToken68(challenge, start, currentIndex); + token = new AuthenticateChallengeToken(null, token68, null); + } else { + createAuthParamToken(start, token68OrParamKeyEnd, iterateUntilNextNonSpace()); + } + } else { + int equalsIndex = currentIndex; + + // If the equals index is the last character or the next character is another equal sign, then it's a + // token68. + if (currentIndex + 1 == challengeLength || challenge.charAt(currentIndex + 1) == '=') { + // Two equal signs in a row, must be a token68. + c = iterateUntil(c1 -> c1 != '='); + token = new AuthenticateChallengeToken(null, validateToken68(challenge, start, currentIndex), null); + } else { + // Otherwise check what the next non-space character is after the equals sign. If it's a comma or end of + // challenge then it's a token68, otherwise it's an auth-param. + c = iterateUntilNextNonSpace(); + if (c == ',' || currentIndex == challengeLength) { + // It's a token68. + token = new AuthenticateChallengeToken(null, validateToken68(challenge, start, equalsIndex + 1), + null); + } else { + // It's a challenge parameter. + c = createAuthParamToken(start, equalsIndex, c); + } + } + + // If the character following the last equal sign isn't a comma or end of challenge, there is an error. + c = (c == ',' || currentIndex == challengeLength) ? c : iterateUntilNextNonSpace(); + if (currentIndex < challengeLength && c != ',') { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException( + "Challenge had more than one token68 or auth-param in the same comma separator.")); + } + } + + // Update state to the next state, which is CHALLENGE_SEPARATOR and skip over the comma. + state = State.CHALLENGE_SEPARATOR; + iterateUntilNextNonSpace(); + } + + private char createAuthParamToken(int keyStartInclusive, int keyEndExclusive, char currentChar) { + String authParamKey = challenge.substring(keyStartInclusive, keyEndExclusive); + if (!isValidToken(authParamKey)) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .addKeyValue("authParamKey", authParamKey) + .log(new IllegalArgumentException("Auth-param key contained an invalid character.")); + } + + int start = currentIndex; + String authParamValue; + if (currentChar == '"') { + // Iterate until the next character, which is either the start of a quoted-string or another '"' denoting an + // empty quoted-string. + currentIndex++; + start++; + + // Iterate until a closing double quote which isn't escaped by a backslash. + currentChar = iterateUntil(c1 -> c1 == '"' && challenge.charAt(currentIndex - 1) != '\\'); + if (currentChar != '"') { + // Only time this should happen is reaching the end of the challenge. + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException("Quoted-string was not terminated with a double quote.")); + } + + authParamValue = challenge.substring(start, currentIndex).replace("\\\\", ""); + } else { + // Handle as a token. + currentChar = iterateUntil(c1 -> c1 == ' ' || c1 == '\t' || c1 == ','); + authParamValue = challenge.substring(start, currentIndex); + if (!isValidToken(authParamValue)) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .addKeyValue("authParamValue", authParamValue) + .log(new IllegalArgumentException("Auth-param value contained an invalid character.")); + } + } + + // Iterate until the next non-space character. + currentChar = (currentChar == ',') ? currentChar : iterateUntilNextNonSpace(); + + // After the scheme only a single token68 or auth-param is allowed. If after any trailing spaces the next + // character isn't a comma throw an exception. + if (currentIndex < challengeLength && currentChar != ',') { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .log(new IllegalArgumentException( + "Challenge had more than one token68 or auth-param in the same comma separator.")); + } + + token = new AuthenticateChallengeToken(null, null, new AbstractMap.SimpleEntry<>(authParamKey, authParamValue)); + return currentChar; + } + + private void handleChallenge() { + // If the state is CHALLENGE_SEPARATOR, then the next token can be either new challenge scheme or an auth-param. + // Skip any leading spaces. + // Search until an equal sign, comma, space, or end of challenge is found. + // If a comma is found or the end of challenge reached the current token is a scheme without any auth-params or + // a token68. + // If an equal sign is found, then the current token is an auth-param as that isn't allowed in a scheme. + // If a space is found search for the next non-space character. If it's an equal sign then it's an auth-param, + // otherwise this is the beginning of a new scheme. + int start = currentIndex; + + char c = iterateUntil(c1 -> c1 == ' ' || c1 == '\t' || c1 == ',' || c1 == '='); + + if (c == ',' || currentIndex == challengeLength) { + // Scheme without any auth-params or a token68. + // handleSchemeToken will set the state to SCHEME or CHALLENGE_START based on the next character. + token = handleSchemeToken(start, currentIndex, c, true); + } else if (c == '=') { + // Auth-param for the challenge currently being parsed. + createAuthParamToken(start, currentIndex, iterateUntilNextNonSpace()); + state = State.CHALLENGE_SEPARATOR; + iterateUntilNextNonSpace(); + } else { + // Can either be a new scheme or an auth-param based on the next non-space character. + int end = currentIndex; // This end will either be the new scheme end or the auth-param key end. + c = iterateUntilNextNonSpace(); + if (c == '=') { + // Auth-param for the challenge currently being parsed. + createAuthParamToken(start, end, iterateUntilNextNonSpace()); + state = State.CHALLENGE_SEPARATOR; + iterateUntilNextNonSpace(); + } else { + // New scheme. + token = handleSchemeToken(start, end, c, true); + } + } + } + + private static boolean isValidTokenCharacter(char c) { + // token = 1*( ALPHA / DIGIT / ! / # / $ / % / & / ' / * / + / - / . / ^ / _ / ` / | / ~ ) + return c < 128 && VALID_TOKEN_CHARS[c]; + } + + private static boolean isValidToken(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (!isValidTokenCharacter(c)) { + return false; + } + } + + return true; + } + + private static boolean isValidToken68Character(char c) { + // token68 = 1*( ALPHA / DIGIT / - / . / _ / ~ / + / '/' ) *"=" + return c < 128 && VALID_TOKEN68_CHARS[c]; + } + + private static String validateToken68(String challenge, int start, int end) { + for (int i = start; i < end; i++) { + char c = challenge.charAt(i); + if (c == '=') { + // From this point onwards the only valid character is '='. + i++; + while (i < end) { + c = challenge.charAt(i); + if (c != '=') { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .addKeyValue("token68", challenge.substring(start, end)) + .addKeyValue("character", c) + .log(new IllegalArgumentException("Token68 contained invalid character.")); + } + + i++; + } + } else if (!isValidToken68Character(c)) { + throw LOGGER.atError() + .addKeyValue("challenge", challenge) + .addKeyValue("token68", challenge.substring(start, end)) + .addKeyValue("character", c) + .log(new IllegalArgumentException("Token68 contained invalid character.")); + } + } + + return challenge.substring(start, end); + } + + private enum State { + BEGINNING, CHALLENGE_SEPARATOR, SCHEME + } + + private static class AuthenticateChallengeToken { + final String scheme; + final String token68; + final Map.Entry authParam; + + AuthenticateChallengeToken(String scheme, String token68, Map.Entry authParam) { + this.scheme = scheme; + this.token68 = token68; + this.authParam = authParam; + } + } +} diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/policy/AuthorizationChallengeParser.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/policy/AuthorizationChallengeParser.java index e0617cc95580..8f10fd8f54e7 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/policy/AuthorizationChallengeParser.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/policy/AuthorizationChallengeParser.java @@ -111,8 +111,7 @@ private static String getChallengeParameterValue(String parameters, String param String key = pair.substring(0, equalsIndex).trim(); if (key.equals(parameter)) { - String value = pair.substring(equalsIndex + 1).replace("\"", "").trim(); - return value; + return pair.substring(equalsIndex + 1).replace("\"", "").trim(); } } } diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/util/AuthenticateChallenge.java b/sdk/core/azure-core/src/main/java/com/azure/core/util/AuthenticateChallenge.java new file mode 100644 index 000000000000..66fe4ba4a64f --- /dev/null +++ b/sdk/core/azure-core/src/main/java/com/azure/core/util/AuthenticateChallenge.java @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.core.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.util.logging.ClientLogger; + +import java.util.Collections; +import java.util.Map; + +/** + * An authenticate challenge. + *

+ * This challenge can be from any source, but will primarily be from parsing {@link HttpHeaderName#WWW_AUTHENTICATE} or + * {@link HttpHeaderName#PROXY_AUTHENTICATE} headers using {@link CoreUtils#parseAuthenticateHeader(String)}. + *

+ * Some challenge information may be optional, meaning the getters may return null or an empty collection. + */ +public final class AuthenticateChallenge { + private static final ClientLogger LOGGER = new ClientLogger(AuthenticateChallenge.class); + + private final String scheme; + private final Map parameters; + private final String token68; + + /** + * Creates an instance of the AuthenticateChallenge. + * + * @param scheme The scheme of the challenge. + * @throws IllegalArgumentException If the scheme is null or empty. + */ + public AuthenticateChallenge(String scheme) { + this(scheme, Collections.emptyMap(), null); + } + + /** + * Creates an instance of the AuthenticateChallenge. + * + * @param scheme The scheme of the challenge. + * @param token68 The token68 of the challenge. + * @throws IllegalArgumentException If the scheme is null or empty. + */ + public AuthenticateChallenge(String scheme, String token68) { + this(scheme, Collections.emptyMap(), token68); + } + + /** + * Creates an instance of the AuthenticateChallenge. + * + * @param scheme The scheme of the challenge. + * @param parameters The parameters of the challenge. + * @throws IllegalArgumentException If the scheme is null or empty. + */ + public AuthenticateChallenge(String scheme, Map parameters) { + this(scheme, parameters, null); + } + + AuthenticateChallenge(String scheme, Map parameters, String token68) { + if (CoreUtils.isNullOrEmpty(scheme)) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException("scheme cannot be null or empty.")); + } + + this.scheme = scheme; + this.parameters = Collections.unmodifiableMap(parameters); + this.token68 = token68; + } + + /** + * Gets the scheme of the challenge. + * + * @return The scheme of the challenge. + */ + public String getScheme() { + return scheme; + } + + /** + * Gets the parameters of the challenge as a read-only map. + *

+ * This map will be empty if the challenge does not have any parameters. + * + * @return The parameters of the challenge. + */ + public Map getParameters() { + return parameters; + } + + /** + * Gets the token68 of the challenge. + *

+ * This may be null if the challenge does not have a token68. + * + * @return The token68 of the challenge, or null if the challenge does not have a token68. + */ + public String getToken68() { + return token68; + } +} diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java b/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java index 05a662da4d22..e0d4bd605b52 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java @@ -3,10 +3,12 @@ package com.azure.core.util; +import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; import com.azure.core.http.policy.HttpLogOptions; import com.azure.core.http.rest.PagedResponse; import com.azure.core.implementation.ImplUtils; +import com.azure.core.implementation.http.AuthenticateChallengeParser; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.logging.LogLevel; import org.reactivestreams.Publisher; @@ -798,4 +800,34 @@ public static OffsetDateTime parseBestOffsetDateTime(String dateString) { return OffsetDateTime.from(temporal); } } + + /** + * Processes an authenticate header, such as {@link HttpHeaderName#WWW_AUTHENTICATE} or + * {@link HttpHeaderName#PROXY_AUTHENTICATE}, into a list of {@link AuthenticateChallenge}. + *

+ * If the {@code authenticateHeader} is null or empty an empty list will be returned. + *

+ * This method will parse the authenticate header as plainly as possible, meaning no casing will be changed on the + * scheme and no decoding will be done on the parameters. The only processing done is removal of quotes around + * parameter values and backslashes escaping values. Ex, {@code "va\"lue"} will be parsed as {@code va"lue}. + *

+ * In addition to processing as plainly as possible, this method will not validate the authenticate header, it will + * only parse it. Though, if the authenticate header has syntax errors an {@link IllegalStateException} will be + * thrown. + *

+ * A list of {@link AuthenticateChallenge} will be returned as it is valid for multiple authenticate challenges to + * use the same scheme, therefore a map cannot be used as the scheme would be the key and only one challenge would + * be stored. + * + * @param authenticateHeader The authenticate header to be parsed. + * @return A list of authenticate challenges. + * @throws IllegalArgumentException If the {@code authenticateHeader} has syntax errors. + */ + public static List parseAuthenticateHeader(String authenticateHeader) { + if (isNullOrEmpty(authenticateHeader)) { + return Collections.emptyList(); + } + + return new AuthenticateChallengeParser(authenticateHeader).parse(); + } } diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/util/CoreUtilsTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/util/CoreUtilsTests.java index 78fd2291c906..39548d4a2cff 100644 --- a/sdk/core/azure-core/src/test/java/com/azure/core/util/CoreUtilsTests.java +++ b/sdk/core/azure-core/src/test/java/com/azure/core/util/CoreUtilsTests.java @@ -21,10 +21,11 @@ import java.time.OffsetDateTime; import java.util.AbstractMap; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -38,6 +39,9 @@ import java.util.function.Function; import java.util.stream.Stream; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -132,7 +136,7 @@ public void isNullOrEmptyCollection(Collection collection, boolean expected) private static Stream isNullOrEmptyCollectionSupplier() { return Stream.of(Arguments.of(null, true), Arguments.of(new ArrayList<>(), true), - Arguments.of(Collections.singletonList(1), false)); + Arguments.of(singletonList(1), false)); } @ParameterizedTest @@ -235,8 +239,8 @@ private static Stream createHttpHeadersFromClientOptionsSupplier() { Arguments.of(new ClientOptions(), null), // ClientOptions contains a single header value, a single header HttpHeaders is returned. - Arguments.of(new ClientOptions().setHeaders(Collections.singletonList(new Header("a", "header"))), - new HttpHeaders(Collections.singletonMap("a", "header"))), + Arguments.of(new ClientOptions().setHeaders(singletonList(new Header("a", "header"))), + new HttpHeaders(singletonMap("a", "header"))), // ClientOptions contains multiple header values, a multi-header HttpHeaders is returned. Arguments.of(new ClientOptions().setHeaders(multipleHeadersList), new HttpHeaders(multipleHeadersMap))); @@ -626,4 +630,66 @@ public void parseBestNoColonInTimezoneOffset() { assertEquals(5, parsed.getSecond()); assertEquals(0, parsed.getOffset().getTotalSeconds()); } + + @ParameterizedTest + @MethodSource("validParseAuthenticateHeaderSupplier") + @Execution(ExecutionMode.SAME_THREAD) + public void validParseAuthenticateHeader(String authenticationHeader, List expected) { + List actual = CoreUtils.parseAuthenticateHeader(authenticationHeader); + + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertEquals(expected.get(i).getScheme(), actual.get(i).getScheme()); + assertEquals(expected.get(i).getParameters(), actual.get(i).getParameters()); + assertEquals(expected.get(i).getToken68(), actual.get(i).getToken68()); + } + } + + private static Stream validParseAuthenticateHeaderSupplier() { + Map digestParams = new LinkedHashMap<>(); + digestParams.put("nonce", "123"); + digestParams.put("opaque", "123"); + digestParams.put("qop", "123"); + digestParams.put("algorithm", "SHA-256"); + + return Stream.of(Arguments.of(null, emptyList()), Arguments.of("", emptyList()), + Arguments.of("Basic", singletonList(new AuthenticateChallenge("Basic"))), + Arguments.of("Basic realm=\"test\"", + singletonList(new AuthenticateChallenge("Basic", singletonMap("realm", "test")))), + Arguments.of("Custom ABkd856gkslw-._~+/=", + singletonList(new AuthenticateChallenge("Custom", "ABkd856gkslw-._~+/="))), + Arguments.of("Digest nonce = \"123\", opaque=\"123\", qop=\"123\", algorithm=SHA-256", + singletonList(new AuthenticateChallenge("Digest", digestParams))), + Arguments.of("Digest nonce = \"123\", opaque=\"123\", qop=\"123\", algorithm=SHA-256, Basic realm=\"test\"", + Arrays.asList(new AuthenticateChallenge("Digest", digestParams), + new AuthenticateChallenge("Basic", singletonMap("realm", "test")))), + Arguments.of("Basic realm=\"test\", Basic realm=\"test2\"", + Arrays.asList(new AuthenticateChallenge("Basic", singletonMap("realm", "test")), + new AuthenticateChallenge("Basic", singletonMap("realm", "test2")))), + Arguments.of(" , ,, ,, Basic realm=\"test\"", + singletonList(new AuthenticateChallenge("Basic", singletonMap("realm", "test")))), + Arguments.of("Custom1,Custom2", + Arrays.asList(new AuthenticateChallenge("Custom1"), new AuthenticateChallenge("Custom2")))); + } + + @ParameterizedTest + @MethodSource("invalidParseAuthenticateHeaderSupplier") + @Execution(ExecutionMode.SAME_THREAD) + public void invalidParseAuthenticateHeader(String authenticationHeader) { + assertThrows(IllegalArgumentException.class, () -> CoreUtils.parseAuthenticateHeader(authenticationHeader)); + } + + private static Stream invalidParseAuthenticateHeaderSupplier() { + return Stream.of("ABkd856gkslw-._~+/=", // token68 without scheme + "realm=\"test\"", // auth-param without scheme + "Custom ABkd856gkslw-._~+/, ABkd856gkslw-._~+/", // multiple token68s + "Custom ABkd856gkslw-._~+/, realm=\"test\"", // token68 and auth-param + "Custom realm=\"test\", ABkd856gkslw-._~+/", // auth-param and token68 + "Custom/", // scheme with invalid character + "Custom ABkd856gkslw-._~+/!", // token68 with invalid character + "Custom realm=test/", // auth-param with invalid character + "Custom realm=test realm2=test2", // missing comma between auth-params + "Custom realm=\"" // missing closing quote for auth-param + ); + } }