Support basic and digest authentication.

Authentication sequence in RTSP:

- Server replies "Unauthorized" to our DESCRIBE request, and includes the
  necessary information (i.e. realm, digest nonce, etc) in WWW-Authenticate
  header

- After `RtspClient` receives the response, we

  - Parse the WWW-Authenticate header, stores the auth info. The info is saved
    for all further RTSP requests (that all need to carry authorization headers)
  - send the second DESCRIBE request with the Authorization header.

#minor-release

PiperOrigin-RevId: 376116302
This commit is contained in:
claincly 2021-05-27 09:02:47 +01:00 committed by Oliver Woodman
parent 8f04d36f59
commit 0fb5fa75cf
6 changed files with 436 additions and 21 deletions

View File

@ -68,6 +68,8 @@
* Fix `NullPointerException` in `StyledPlayerView` that could occur after
calling `StyledPlayerView.setPlayer(null)`
([#8985](https://github.com/google/ExoPlayer/issues/8985)).
* RTSP:
* Add support for RTSP basic and digest authentication.
### 2.14.0 (2021-05-13)

View File

@ -0,0 +1,142 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.source.rtsp;
import android.net.Uri;
import android.util.Base64;
import androidx.annotation.IntDef;
import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.source.rtsp.RtspMessageUtil.RtspAuthUserInfo;
import com.google.android.exoplayer2.util.Util;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/** Wraps RTSP authentication information. */
/* package */ final class RtspAuthenticationInfo {
/** The supported authentication methods. */
@Retention(RetentionPolicy.SOURCE)
@IntDef({BASIC, DIGEST})
@interface AuthenticationMechanism {}
/** HTTP basic authentication (RFC2068 Section 11.1). */
public static final int BASIC = 1;
/** HTTP digest authentication (RFC2069). */
public static final int DIGEST = 2;
private static final String DIGEST_FORMAT =
"Digest username=\"%s\", realm=\"%s\", nonce=\"%s\", uri=\"%s\", response=\"%s\"";
private static final String DIGEST_FORMAT_WITH_OPAQUE =
"Digest username=\"%s\", realm=\"%s\", nonce=\"%s\", uri=\"%s\", response=\"%s\","
+ " opaque=\"%s\"";
private static final String ALGORITHM = "MD5";
/** The authentication mechanism. */
@AuthenticationMechanism public final int authenticationMechanism;
/** The authentication realm. */
public final String realm;
/** The nonce used in digest authentication; empty if using {@link #BASIC} authentication. */
public final String nonce;
/** The opaque used in digest authentication; empty if using {@link #BASIC} authentication. */
public final String opaque;
/**
* Creates a new instance.
*
* @param authenticationMechanism The authentication mechanism, as defined by {@link
* AuthenticationMechanism}.
* @param realm The authentication realm.
* @param nonce The nonce in digest authentication; empty if using {@link #BASIC} authentication.
* @param opaque The opaque in digest authentication; empty if using {@link #BASIC}
* authentication.
*/
public RtspAuthenticationInfo(
@AuthenticationMechanism int authenticationMechanism,
String realm,
String nonce,
String opaque) {
this.authenticationMechanism = authenticationMechanism;
this.realm = realm;
this.nonce = nonce;
this.opaque = opaque;
}
/**
* Gets the string value for {@link RtspHeaders#AUTHORIZATION} header.
*
* @param authUserInfo The {@link RtspAuthUserInfo} for authentication.
* @param uri The request {@link Uri}.
* @param requestMethod The request method, defined in {@link RtspRequest.Method}.
* @return The string value for {@link RtspHeaders#AUTHORIZATION} header.
* @throws ParserException If the MD5 algorithm is not supported by {@link MessageDigest}.
*/
public String getAuthorizationHeaderValue(
RtspAuthUserInfo authUserInfo, Uri uri, @RtspRequest.Method int requestMethod)
throws ParserException {
switch (authenticationMechanism) {
case BASIC:
return getBasicAuthorizationHeaderValue(authUserInfo);
case DIGEST:
return getDigestAuthorizationHeaderValue(authUserInfo, uri, requestMethod);
default:
throw new ParserException(new UnsupportedOperationException());
}
}
private String getBasicAuthorizationHeaderValue(RtspAuthUserInfo authUserInfo) {
return Base64.encodeToString(
RtspMessageUtil.getStringBytes(authUserInfo.username + ":" + authUserInfo.password),
Base64.DEFAULT);
}
private String getDigestAuthorizationHeaderValue(
RtspAuthUserInfo authUserInfo, Uri uri, @RtspRequest.Method int requestMethod)
throws ParserException {
try {
MessageDigest md = MessageDigest.getInstance(ALGORITHM);
String methodName = RtspMessageUtil.toMethodString(requestMethod);
// From RFC2069 Section 2.1.2:
// response-digest = H( H(A1) ":" unquoted nonce-value ":" H(A2) )
// A1 = unquoted username-value ":" unquoted realm-value ":" password
// A2 = Method ":" request-uri
// H(x) = MD5(x)
String hashA1 =
Util.toHexString(
md.digest(
RtspMessageUtil.getStringBytes(
authUserInfo.username + ":" + realm + ":" + authUserInfo.password)));
String hashA2 =
Util.toHexString(md.digest(RtspMessageUtil.getStringBytes(methodName + ":" + uri)));
String response =
Util.toHexString(
md.digest(RtspMessageUtil.getStringBytes(hashA1 + ":" + nonce + ":" + hashA2)));
if (opaque.isEmpty()) {
return Util.formatInvariant(
DIGEST_FORMAT, authUserInfo.username, realm, nonce, uri, response);
} else {
return Util.formatInvariant(
DIGEST_FORMAT_WITH_OPAQUE, authUserInfo.username, realm, nonce, uri, response, opaque);
}
} catch (NoSuchAlgorithmException e) {
throw new ParserException(e);
}
}
}

View File

@ -32,6 +32,7 @@ import static com.google.android.exoplayer2.source.rtsp.RtspRequest.METHOD_UNSET
import static com.google.android.exoplayer2.util.Assertions.checkArgument;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Assertions.checkState;
import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull;
import static com.google.common.base.Strings.nullToEmpty;
import android.net.Uri;
@ -44,6 +45,7 @@ import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.source.rtsp.RtspMediaPeriod.RtpLoadInfo;
import com.google.android.exoplayer2.source.rtsp.RtspMediaSource.RtspPlaybackException;
import com.google.android.exoplayer2.source.rtsp.RtspMessageChannel.InterleavedBinaryDataListener;
import com.google.android.exoplayer2.source.rtsp.RtspMessageUtil.RtspAuthUserInfo;
import com.google.android.exoplayer2.source.rtsp.RtspMessageUtil.RtspSessionHeader;
import com.google.android.exoplayer2.util.Util;
import com.google.common.collect.ImmutableList;
@ -92,6 +94,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private final SessionInfoListener sessionInfoListener;
private final Uri uri;
@Nullable private final RtspAuthUserInfo rtspAuthUserInfo;
@Nullable private final String userAgent;
private final ArrayDeque<RtpLoadInfo> pendingSetupRtpLoadInfos;
// TODO(b/172331505) Add a timeout monitor for pending requests.
@ -103,7 +106,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Nullable private String sessionId;
@Nullable private KeepAliveMonitor keepAliveMonitor;
private boolean hasUpdatedTimelineAndTracks;
private boolean receivedAuthorizationRequest;
private long pendingSeekPositionUs;
private @MonotonicNonNull RtspAuthenticationInfo rtspAuthenticationInfo;
/**
* Creates a new instance.
@ -122,6 +127,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
public RtspClient(SessionInfoListener sessionInfoListener, @Nullable String userAgent, Uri uri) {
this.sessionInfoListener = sessionInfoListener;
this.uri = RtspMessageUtil.removeUserInfo(uri);
this.rtspAuthUserInfo = RtspMessageUtil.parseUserInfo(uri);
this.userAgent = userAgent;
pendingSetupRtpLoadInfos = new ArrayDeque<>();
pendingRequests = new SparseArray<>();
@ -212,6 +218,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
messageChannel = new RtspMessageChannel(new MessageListener());
messageChannel.open(getSocket(uri));
sessionId = null;
receivedAuthorizationRequest = false;
} catch (IOException e) {
checkNotNull(playbackEventListener).onPlaybackError(new RtspPlaybackException(e));
}
@ -239,6 +246,20 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return SocketFactory.getDefault().createSocket(checkNotNull(uri.getHost()), rtspPort);
}
private void dispatchRtspError(Throwable error) {
RtspPlaybackException playbackException =
error instanceof RtspPlaybackException
? (RtspPlaybackException) error
: new RtspPlaybackException(error);
if (hasUpdatedTimelineAndTracks) {
// Playback event listener must be non-null after timeline has been updated.
checkNotNull(playbackEventListener).onPlaybackError(playbackException);
} else {
sessionInfoListener.onSessionTimelineRequestFailed(nullToEmpty(error.getMessage()), error);
}
}
/**
* Returns whether the RTSP server supports the DESCRIBE method.
*
@ -334,6 +355,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
headersBuilder.add(RtspHeaders.SESSION, sessionId);
}
if (rtspAuthenticationInfo != null) {
checkStateNotNull(rtspAuthUserInfo);
try {
headersBuilder.add(
RtspHeaders.AUTHORIZATION,
rtspAuthenticationInfo.getAuthorizationHeaderValue(rtspAuthUserInfo, uri, method));
} catch (ParserException e) {
dispatchRtspError(new RtspPlaybackException(e));
}
}
headersBuilder.addAll(additionalHeaders);
return new RtspRequest(uri, method, headersBuilder.build(), /* messageBody= */ "");
}
@ -379,14 +411,33 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@RtspRequest.Method int requestMethod = matchingRequest.method;
if (response.status != 200) {
try {
switch (response.status) {
case 200:
break;
case 401:
if (rtspAuthUserInfo != null && !receivedAuthorizationRequest) {
// Unauthorized.
@Nullable
String wwwAuthenticateHeader = response.headers.get(RtspHeaders.WWW_AUTHENTICATE);
if (wwwAuthenticateHeader == null) {
throw new ParserException("Missing WWW-Authenticate header in a 401 response.");
}
rtspAuthenticationInfo =
RtspMessageUtil.parseWwwAuthenticateHeader(wwwAuthenticateHeader);
messageSender.sendDescribeRequest(uri, sessionId);
receivedAuthorizationRequest = true;
return;
}
// fall through: if unauthorized and no userInfo present, or previous authentication
// unsuccessful.
default:
dispatchRtspError(
new RtspPlaybackException(
RtspMessageUtil.toMethodString(requestMethod) + " " + response.status));
return;
}
try {
switch (requestMethod) {
case METHOD_OPTIONS:
onOptionsResponseReceived(
@ -505,20 +556,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
startPlayback(C.usToMs(pendingSeekPositionUs));
}
}
private void dispatchRtspError(Throwable error) {
RtspPlaybackException playbackException =
error instanceof RtspPlaybackException
? (RtspPlaybackException) error
: new RtspPlaybackException(error);
if (hasUpdatedTimelineAndTracks) {
// Playback event listener must be non-null after timeline has been updated.
checkNotNull(playbackEventListener).onPlaybackError(playbackException);
} else {
sessionInfoListener.onSessionTimelineRequestFailed(nullToEmpty(error.getMessage()), error);
}
}
}
/** Sends periodic OPTIONS requests to keep RTSP connection alive. */

View File

@ -30,6 +30,7 @@ import static com.google.android.exoplayer2.source.rtsp.RtspRequest.METHOD_TEARD
import static com.google.android.exoplayer2.source.rtsp.RtspRequest.METHOD_UNSET;
import static com.google.android.exoplayer2.util.Assertions.checkArgument;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.common.base.Strings.nullToEmpty;
import static java.util.regex.Pattern.CASE_INSENSITIVE;
import android.net.Uri;
@ -63,6 +64,20 @@ import java.util.regex.Pattern;
}
}
/** Wraps username and password for authentication purposes. */
public static final class RtspAuthUserInfo {
/** The username. */
public final String username;
/** The password. */
public final String password;
/** Creates a new instance. */
public RtspAuthUserInfo(String username, String password) {
this.username = username;
this.password = password;
}
}
/** The default timeout, in milliseconds, defined for RTSP (RFC2326 Section 12.37). */
public static final long DEFAULT_RTSP_TIMEOUT_MS = 60_000;
@ -80,6 +95,17 @@ import java.util.regex.Pattern;
private static final Pattern SESSION_HEADER_PATTERN =
Pattern.compile("(\\w+)(?:;\\s?timeout=(\\d+))?");
// WWW-Authenticate header pattern, see RFC2068 Sections 14.46 and RFC2069.
private static final Pattern WWW_AUTHENTICATION_HEADER_DIGEST_PATTERN =
Pattern.compile(
"Digest realm=\"([\\w\\s@.]+)\""
+ ",\\s?(?:domain=\"(.+)\",\\s?)?"
+ "nonce=\"(\\w+)\""
+ "(?:,\\s?opaque=\"(\\w+)\")?");
// WWW-Authenticate header pattern, see RFC2068 Section 11.1 and RFC2069.
private static final Pattern WWW_AUTHENTICATION_HEADER_BASIC_PATTERN =
Pattern.compile("Basic realm=\"([\\w\\s@.]+)\"");
private static final String RTSP_VERSION = "RTSP/1.0";
/**
@ -157,6 +183,31 @@ import java.util.regex.Pattern;
return uri.buildUpon().encodedAuthority(authority).build();
}
/**
* Parses the user info encapsulated in the RTSP {@link Uri}.
*
* @param uri The {@link Uri}.
* @return The extracted {@link RtspAuthUserInfo}, {@code null} if the argument {@link Uri} does
* not contain userinfo, or it's not properly formatted.
*/
@Nullable
public static RtspAuthUserInfo parseUserInfo(Uri uri) {
@Nullable String userInfo = uri.getUserInfo();
if (userInfo == null) {
return null;
}
if (userInfo.contains(":")) {
String[] userInfoStrings = Util.splitAtFirst(userInfo, ":");
return new RtspAuthUserInfo(userInfoStrings[0], userInfoStrings[1]);
}
return null;
}
/** Returns the byte array representation of a string, using RTSP's character encoding. */
public static byte[] getStringBytes(String s) {
return s.getBytes(RtspMessageChannel.CHARSET);
}
/** Returns the corresponding String representation of the {@link RtspRequest.Method} argument. */
public static String toMethodString(@RtspRequest.Method int method) {
switch (method) {
@ -345,6 +396,39 @@ import java.util.regex.Pattern;
return new RtspSessionHeader(sessionId, timeoutMs);
}
/**
* Parses a WWW-Authenticate header.
*
* <p>Reference RFC2068 Section 14.46 for WWW-Authenticate header. Only digest and basic
* authentication mechanisms are supported.
*
* @param headerValue The string representation of the content, without the header name
* (WWW-Authenticate: ).
* @return The parsed {@link RtspAuthenticationInfo}.
* @throws ParserException When the input header value does not follow the WWW-Authenticate header
* format, or is not using either Basic or Digest mechanisms.
*/
public static RtspAuthenticationInfo parseWwwAuthenticateHeader(String headerValue)
throws ParserException {
Matcher matcher = WWW_AUTHENTICATION_HEADER_DIGEST_PATTERN.matcher(headerValue);
if (matcher.find()) {
return new RtspAuthenticationInfo(
RtspAuthenticationInfo.DIGEST,
/* realm= */ checkNotNull(matcher.group(1)),
/* nonce= */ checkNotNull(matcher.group(3)),
/* opaque= */ nullToEmpty(matcher.group(4)));
}
matcher = WWW_AUTHENTICATION_HEADER_BASIC_PATTERN.matcher(headerValue);
if (matcher.matches()) {
return new RtspAuthenticationInfo(
RtspAuthenticationInfo.BASIC,
/* realm= */ checkNotNull(matcher.group(1)),
/* nonce= */ "",
/* opaque= */ "");
}
throw new ParserException("Invalid WWW-Authenticate header " + headerValue);
}
private static String getRtspStatusReasonPhrase(int statusCode) {
switch (statusCode) {
case 200:

View File

@ -0,0 +1,69 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.source.rtsp;
import static com.google.common.truth.Truth.assertThat;
import android.net.Uri;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.source.rtsp.RtspMessageUtil.RtspAuthUserInfo;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link RtspAuthenticationInfo}. */
@RunWith(AndroidJUnit4.class)
public class RtspAuthenticationInfoTest {
@Test
public void getAuthorizationHeaderValue_withBasicAuthenticationMechanism_getsCorrectHeaderValue()
throws Exception {
String authenticationRealm = "WallyWorld";
String username = "Aladdin";
String password = "open sesame";
String expectedAuthorizationHeaderValue = "QWxhZGRpbjpvcGVuIHNlc2FtZQ==\n";
RtspAuthenticationInfo authenticator =
new RtspAuthenticationInfo(
RtspAuthenticationInfo.BASIC, authenticationRealm, /* nonce= */ "", /* opaque= */ "");
assertThat(
authenticator.getAuthorizationHeaderValue(
new RtspAuthUserInfo(username, password), Uri.EMPTY, RtspRequest.METHOD_DESCRIBE))
.isEqualTo(expectedAuthorizationHeaderValue);
}
@Test
public void getAuthorizationHeaderValue_withDigestAuthenticationMechanism_getsCorrectHeaderValue()
throws Exception {
RtspAuthenticationInfo authenticator =
new RtspAuthenticationInfo(
RtspAuthenticationInfo.DIGEST,
/* realm= */ "LIVE555 Streaming Media",
/* nonce= */ "0cdfe9719e7373b7d5bb2913e2115f3f",
/* opaque= */ "5ccc069c403ebaf9f0171e9517f40e41");
assertThat(
authenticator.getAuthorizationHeaderValue(
new RtspAuthUserInfo("username", "password"),
Uri.parse("rtsp://localhost:554/imax_cd_2k_264_6ch.mkv"),
RtspRequest.METHOD_DESCRIBE))
.isEqualTo(
"Digest username=\"username\", realm=\"LIVE555 Streaming Media\","
+ " nonce=\"0cdfe9719e7373b7d5bb2913e2115f3f\","
+ " uri=\"rtsp://localhost:554/imax_cd_2k_264_6ch.mkv\","
+ " response=\"ba9433847439387776f7fb905db3fcae\","
+ " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"");
}
}

View File

@ -19,7 +19,9 @@ package com.google.android.exoplayer2.source.rtsp;
import static com.google.common.truth.Truth.assertThat;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.source.rtsp.RtspMessageUtil.RtspAuthUserInfo;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ListMultimap;
import java.util.Arrays;
@ -399,4 +401,83 @@ public final class RtspMessageUtilTest {
assertThat(RtspMessageUtil.isRtspStartLine("Transport: RTP/AVP;unicast;client_port=1000-1001"))
.isFalse();
}
@Test
public void extractUserInfo_withoutPassword_returnsNull() {
@Nullable
RtspAuthUserInfo authUserInfo =
RtspMessageUtil.parseUserInfo(Uri.parse("rtsp://username@mediaserver.com/stream1"));
assertThat(authUserInfo).isNull();
}
@Test
public void extractUserInfo_withoutUserInfo_returnsNull() {
@Nullable
RtspAuthUserInfo authUserInfo =
RtspMessageUtil.parseUserInfo(Uri.parse("rtsp://mediaserver.com/stream1"));
assertThat(authUserInfo).isNull();
}
@Test
public void extractUserInfo_withProperlyFormattedUri_succeeds() {
@Nullable
RtspAuthUserInfo authUserInfo =
RtspMessageUtil.parseUserInfo(
Uri.parse("rtsp://username:pass:word@mediaserver.com/stream1"));
assertThat(authUserInfo).isNotNull();
assertThat(authUserInfo.username).isEqualTo("username");
assertThat(authUserInfo.password).isEqualTo("pass:word");
}
@Test
public void parseWWWAuthenticateHeader_withBasicAuthentication_succeeds() throws Exception {
RtspAuthenticationInfo authenticationInfo =
RtspMessageUtil.parseWwwAuthenticateHeader("Basic realm=\"WallyWorld\"");
assertThat(authenticationInfo.authenticationMechanism).isEqualTo(RtspAuthenticationInfo.BASIC);
assertThat(authenticationInfo.nonce).isEmpty();
assertThat(authenticationInfo.realm).isEqualTo("WallyWorld");
}
@Test
public void parseWWWAuthenticateHeader_withDigestAuthenticationWithDomain_succeeds()
throws Exception {
RtspAuthenticationInfo authenticationInfo =
RtspMessageUtil.parseWwwAuthenticateHeader(
"Digest realm=\"testrealm@host.com\", domain=\"host.com\","
+ " nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", "
+ " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"");
assertThat(authenticationInfo.authenticationMechanism).isEqualTo(RtspAuthenticationInfo.DIGEST);
assertThat(authenticationInfo.nonce).isEqualTo("dcd98b7102dd2f0e8b11d0f600bfb0c093");
assertThat(authenticationInfo.realm).isEqualTo("testrealm@host.com");
assertThat(authenticationInfo.opaque).isEmpty();
}
@Test
public void parseWWWAuthenticateHeader_withDigestAuthenticationWithOptionalParameters_succeeds()
throws Exception {
RtspAuthenticationInfo authenticationInfo =
RtspMessageUtil.parseWwwAuthenticateHeader(
"Digest realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\","
+ " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\", stale=\"stalev\","
+ " algorithm=\"md5\"");
assertThat(authenticationInfo.authenticationMechanism).isEqualTo(RtspAuthenticationInfo.DIGEST);
assertThat(authenticationInfo.nonce).isEqualTo("dcd98b7102dd2f0e8b11d0f600bfb0c093");
assertThat(authenticationInfo.realm).isEqualTo("testrealm@host.com");
assertThat(authenticationInfo.opaque).isEqualTo("5ccc069c403ebaf9f0171e9517f40e41");
}
@Test
public void parseWWWAuthenticateHeader_withDigestAuthentication_succeeds() throws Exception {
RtspAuthenticationInfo authenticationInfo =
RtspMessageUtil.parseWwwAuthenticateHeader(
"Digest realm=\"LIVE555 Streaming Media\", nonce=\"0cdfe9719e7373b7d5bb2913e2115f3f\"");
assertThat(authenticationInfo.authenticationMechanism).isEqualTo(RtspAuthenticationInfo.DIGEST);
assertThat(authenticationInfo.nonce).isEqualTo("0cdfe9719e7373b7d5bb2913e2115f3f");
assertThat(authenticationInfo.realm).isEqualTo("LIVE555 Streaming Media");
assertThat(authenticationInfo.opaque).isEmpty();
}
}