From 6cd78b44dc5bf8dc3311931d9a41bb93485c1d91 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Thu, 29 Oct 2026 08:04:29 +0100 Subject: [PATCH] feat: authorize WHIP and WHEP by HTTP Basic basicAuth(user, password) on Whip.Builder and Whep.Builder sends Basic credentials (RFC 7617, UTF-8) with every request of the session, as RFC 9725 4.7 asks clients to support HTTP authentication besides bearer tokens; MediaMTX takes them this way. The one of bearerToken and basicAuth called last counts. --- docs/guide/client-api.md | 9 +-- .../dev/onvoid/webrtc/client/HttpCall.java | 20 +++--- .../dev/onvoid/webrtc/client/HttpIce.java | 10 +-- .../dev/onvoid/webrtc/client/HttpSession.java | 69 +++++++++++++------ .../java/dev/onvoid/webrtc/client/WebRtc.java | 9 +-- .../java/dev/onvoid/webrtc/client/Whep.java | 38 +++++++--- .../java/dev/onvoid/webrtc/client/Whip.java | 38 +++++++--- .../webrtc/client/HttpSessionTests.java | 44 ++++++++++++ 8 files changed, 175 insertions(+), 62 deletions(-) diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md index 974769d32..a47908cf4 100644 --- a/docs/guide/client-api.md +++ b/docs/guide/client-api.md @@ -665,15 +665,16 @@ The timeout bounds the requests as a whole, not each read, so a server that send Endpoints have to be `https` URLs, as WHIP and WHEP ask, except on the machine the application runs on: there plain `http` will do, for a media server beside it, at `localhost`, an address in 127.0.0.0/8 or `::1`. A name that merely resolves to one of them does not count, since it may resolve elsewhere later. The same holds for the session an endpoint names and for where a redirect points. -The bearer token goes to the endpoint, and along with the deletion to the session the endpoint names in its `Location` header. That session may be on another host, but an `https` endpoint cannot name a plain `http` one. The offer follows redirects with status 307 and 308, as WHIP and WHEP ask of clients, such as those of a load balancer: up to five of them, all within the timeout, and the token goes along. A redirect from `https` to plain `http` fails the session, and so does one with status 301, 302 or 303, which would turn the offer into a `GET`. The deletion follows no redirect; one fails it with its status. +The credentials of a session, its bearer token or its Basic user and password, go to the endpoint, and along with the deletion to the session the endpoint names in its `Location` header. That session may be on another host, but an `https` endpoint cannot name a plain `http` one. The offer follows redirects with status 307 and 308, as WHIP and WHEP ask of clients, such as those of a load balancer: up to five of them, all within the timeout, and the credentials go along. A redirect from `https` to plain `http` fails the session, and so does one with status 301, 302 or 303, which would turn the offer into a `GET`. The deletion follows no redirect; one fails it with its status. -Servers that ask for a user and password rather than a token, such as MediaMTX, take them as the token, the way OBS Studio sends them: `bearerToken("user:password")` sends `Authorization: Bearer user:password`. +Servers that ask for a user and password rather than a token, such as MediaMTX, take them by HTTP Basic authentication (RFC 7617): `basicAuth("user", "password")` sends `Authorization: Basic` with both, in UTF-8, on every request of the session, without waiting to be asked. The user must not contain a colon. Of `bearerToken` and `basicAuth`, the one called last counts. MediaMTX also takes them as a token, the way OBS Studio sends them, `bearerToken("user:password")`. Other schemes, such as Digest, are not supported. -Requests go through Java's `HttpClient`, with the default `SSLContext` and the HTTP proxy Java is configured with, the first the default `ProxySelector` names: `-Dhttps.proxyHost` and `-Dhttps.proxyPort`, `-Dhttp.proxyHost`, or `-Djava.net.useSystemProxies=true`. `HttpClient` takes no SOCKS proxies. A proxy that asks for credentials gets Basic ones from the default `Authenticator`, but only for sessions without a bearer token: `HttpClient` drops the token of a request once it answers for credentials itself. As in Java, Basic is not used for the tunnel of an `https` request unless `jdk.http.auth.tunneling.disabledSchemes` allows it. Other schemes, such as Digest, NTLM and Negotiate, are not supported. +Requests go through Java's `HttpClient`, with the default `SSLContext` and the HTTP proxy Java is configured with, the first the default `ProxySelector` names: `-Dhttps.proxyHost` and `-Dhttps.proxyPort`, `-Dhttp.proxyHost`, or `-Djava.net.useSystemProxies=true`. `HttpClient` takes no SOCKS proxies. A proxy that asks for credentials gets Basic ones from the default `Authenticator`, but only for sessions without credentials of their own: `HttpClient` drops the Authorization header of a request once it answers for credentials itself. As in Java, Basic is not used for the tunnel of an `https` request unless `jdk.http.auth.tunneling.disabledSchemes` allows it. Other schemes, such as Digest, NTLM and Negotiate, are not supported. | Builder method | What it sets | |---|---| -| `bearerToken(String)` | The token sent as `Authorization: Bearer`; for many services, the stream key, for MediaMTX `user:password`. | +| `bearerToken(String)` | The token sent as `Authorization: Bearer`; for many services, the stream key. | +| `basicAuth(String, String)` | A user and password sent as `Authorization: Basic`, as MediaMTX takes them; replaces a bearer token, and the other way round. | | `iceServer(String...)` | A STUN or TURN server without credentials, overriding endpoint-provided servers. | | `turnServer(String, String, String)` | A TURN URL, username and password, overriding endpoint-provided servers. | | `gathering(Gathering)` | When the initial offer is sent: `TRICKLE` by default, or `COMPLETE` to gather first. | diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java index 13c8f0d55..98f4511b3 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java @@ -87,7 +87,7 @@ final class HttpCall { private final String method; - private final String bearerToken; + private final String authorization; private final Duration timeout; @@ -126,14 +126,16 @@ final class HttpCall { /** - * @param timeout how long the request may take, from now on, until its - * response has been read; redirects included. + * @param authorization the value of the Authorization header, such as + * {@code Bearer }, or null for none. + * @param timeout how long the request may take, from now on, until + * its response has been read; redirects included. */ - HttpCall(HttpClient client, URI uri, String method, String bearerToken, Duration timeout) { + HttpCall(HttpClient client, URI uri, String method, String authorization, Duration timeout) { this.client = client; this.uri = uri; this.method = method; - this.bearerToken = bearerToken; + this.authorization = authorization; this.timeout = timeout; this.start = System.nanoTime(); @@ -160,7 +162,7 @@ final class HttpCall { * Basic credentials with those of the default {@link * Authenticator} of the moment. A client that does * drops the Authorization header of its requests, so - * it cannot send a bearer token. + * it cannot send the credentials of a session. * * @return the client. */ @@ -220,7 +222,7 @@ HttpCall readBodies(Set statuses) { /** * Follows redirects with status 307 and 308, up to {@link #MAX_REDIRECTS} - * of them, with the bearer token. One from https to plain http fails. + * of them, with the credentials. One from https to plain http fails. * * @return this request. */ @@ -302,8 +304,8 @@ private void exchange(URI target, String contentType, byte[] content, int maxBod try { HttpRequest.Builder builder = HttpRequest.newBuilder(target).timeout(Duration.ofNanos(left)); - if (bearerToken != null) { - builder.header("Authorization", "Bearer " + bearerToken); + if (authorization != null) { + builder.header("Authorization", authorization); } headers.forEach(builder::header); diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java index aee9da8e2..005fa4d78 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java @@ -70,7 +70,7 @@ final class HttpIce { private final PeerImpl peer; - private final String bearerToken; + private final String authorization; private final Duration timeout; @@ -110,11 +110,11 @@ final class HttpIce { private boolean closed; - HttpIce(WebRtc rtc, HttpClient client, PeerImpl peer, String bearerToken, Duration timeout) { + HttpIce(WebRtc rtc, HttpClient client, PeerImpl peer, String authorization, Duration timeout) { this.rtc = rtc; this.client = client; this.peer = peer; - this.bearerToken = bearerToken; + this.authorization = authorization; this.timeout = timeout; peer.onLocalCandidates(this::candidate, this::gathered); @@ -191,7 +191,7 @@ CompletableFuture restart() { pending.clear(); gathered = false; - request = new HttpCall(client, session, "PATCH", bearerToken, timeout).header("If-Match", "*"); + request = new HttpCall(client, session, "PATCH", authorization, timeout).header("If-Match", "*"); restartRequest = request; } @@ -343,7 +343,7 @@ private void flush() { pending.clear(); gathered = false; - request = new HttpCall(client, session, "PATCH", bearerToken, timeout); + request = new HttpCall(client, session, "PATCH", authorization, timeout); if (etag != null) { request.header("If-Match", etag); diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java index 7a1fcec2c..ab7dd4898 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java @@ -26,6 +26,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.Set; @@ -71,7 +72,7 @@ abstract class HttpSession implements AutoCloseable { private final URI resource; - private final String bearerToken; + private final String authorization; private final Duration timeout; @@ -85,11 +86,11 @@ abstract class HttpSession implements AutoCloseable { private final CompletableFuture closed = new CompletableFuture<>(); - HttpSession(WebRtc rtc, Peer peer, URI resource, String bearerToken, Duration timeout, HttpIce ice) { + HttpSession(WebRtc rtc, Peer peer, URI resource, String authorization, Duration timeout, HttpIce ice) { this.rtc = rtc; this.peer = peer; this.resource = resource; - this.bearerToken = bearerToken; + this.authorization = authorization; this.timeout = timeout; this.ice = ice; @@ -139,7 +140,7 @@ public CompletableFuture closeAsync() { } CompletableFuture peerClosed = peer.closeAsync().handle((ignored, error) -> error); - CompletableFuture deleted = discard(rtc, resource, bearerToken, timeout); + CompletableFuture deleted = discard(rtc, resource, authorization, timeout); deleted.thenAcceptBoth(peerClosed, (deleteError, peerError) -> { Throwable error = PeerImpl.unwrap(peerError); @@ -204,13 +205,13 @@ public void close() { * @return the future handed to the application. */ static CompletableFuture start(WebRtc rtc, PeerImpl peer, URI endpoint, - String bearerToken, Duration timeout, CompletableFuture prepared, Gathering gathering, + String authorization, Duration timeout, CompletableFuture prepared, Gathering gathering, Protocol protocol, Factory factory) { CompletableFuture abandoned = new CompletableFuture<>(); - HttpIce ice = new HttpIce(rtc, rtc.httpClient(bearerToken != null), peer, bearerToken, timeout); - CompletableFuture session = peer.toPublic(negotiate(rtc, peer, endpoint, bearerToken, timeout, prepared, + HttpIce ice = new HttpIce(rtc, rtc.httpClient(authorization != null), peer, authorization, timeout); + CompletableFuture session = peer.toPublic(negotiate(rtc, peer, endpoint, authorization, timeout, prepared, gathering, protocol, ice, abandoned) - .thenApply(resource -> factory.create(rtc, peer, resource, bearerToken, timeout, ice)), + .thenApply(resource -> factory.create(rtc, peer, resource, authorization, timeout, ice)), HttpSession::close); // Once the session was handed out, this finds the negotiation finished. @@ -237,7 +238,7 @@ static CompletableFuture start(WebRtc rtc, PeerImpl p * * @return a future with the resource the server named, or null. */ - private static CompletableFuture negotiate(WebRtc rtc, PeerImpl peer, URI endpoint, String bearerToken, + private static CompletableFuture negotiate(WebRtc rtc, PeerImpl peer, URI endpoint, String authorization, Duration timeout, CompletableFuture prepared, Gathering gathering, Protocol protocol, HttpIce ice, CompletableFuture abandoned) { CompletableFuture result = new CompletableFuture<>(); @@ -246,10 +247,10 @@ private static CompletableFuture negotiate(WebRtc rtc, PeerImpl peer, URI e // session that connected, or whoever deletes it. AtomicReference resource = new AtomicReference<>(); AtomicBoolean finished = new AtomicBoolean(); - HttpClient client = rtc.httpClient(bearerToken != null); + HttpClient client = rtc.httpClient(authorization != null); // Made now, so that it ends with the session's deadline at the latest, // however long the offer takes. - HttpCall offerRequest = new HttpCall(client, endpoint, "POST", bearerToken, timeout) + HttpCall offerRequest = new HttpCall(client, endpoint, "POST", authorization, timeout) .followRedirects() .retry(rtc.timer, protocol.retried) .readBodies(protocol.counterOffers ? COUNTER_OFFER : Collections.emptySet()); @@ -272,7 +273,7 @@ private static CompletableFuture negotiate(WebRtc rtc, PeerImpl peer, URI e result.completeExceptionally(PeerImpl.asWebRtcException(error, "The session with " + endpoint + " could not be set up")); - discard(rtc, resource.getAndSet(null), bearerToken, timeout); + discard(rtc, resource.getAndSet(null), authorization, timeout); }; ScheduledFuture deadline = rtc.timer.schedule(() -> fail.accept(new WebRtcException( @@ -293,7 +294,7 @@ private static CompletableFuture negotiate(WebRtc rtc, PeerImpl peer, URI e // The session ended while the offer was out, // after the server named what it created, which // then has no one else to delete it. - discard(rtc, resource.getAndSet(null), bearerToken, timeout); + discard(rtc, resource.getAndSet(null), authorization, timeout); } }) .thenCompose(reply -> (gathering == Gathering.COMPLETE @@ -322,7 +323,7 @@ private static CompletableFuture negotiate(WebRtc rtc, PeerImpl peer, URI e // The rest of the session's time, which the answer has. Duration left = timeout.minusNanos(System.nanoTime() - started); - return answerCounterOffer(rtc, client, peer, reply, bearerToken, + return answerCounterOffer(rtc, client, peer, reply, authorization, left.isNegative() || left.isZero() ? Duration.ofNanos(1) : left, resource, ice, request, finished); })) @@ -395,7 +396,7 @@ private static CompletableFuture post(HttpCall request, URI endpoint, Str * @param timeout what is left of the session's timeout. */ private static CompletableFuture answerCounterOffer(WebRtc rtc, HttpClient client, PeerImpl peer, - Reply reply, String bearerToken, Duration timeout, AtomicReference resource, HttpIce ice, + Reply reply, String authorization, Duration timeout, AtomicReference resource, HttpIce ice, AtomicReference request, AtomicBoolean finished) { return peer.answerInstead(new RTCSessionDescription(RTCSdpType.OFFER, reply.sdp), Gathering.COMPLETE) .thenCompose(answer -> { @@ -404,7 +405,7 @@ private static CompletableFuture answerCounterOffer(WebRtc rtc, HttpClient + " expired before it was answered"); } - HttpCall answerRequest = new HttpCall(client, reply.session, "PATCH", bearerToken, timeout); + HttpCall answerRequest = new HttpCall(client, reply.session, "PATCH", authorization, timeout); request.set(answerRequest); if (finished.get()) { @@ -544,7 +545,7 @@ private static final class Reply { */ interface Factory { - S create(WebRtc rtc, Peer peer, URI resource, String bearerToken, Duration timeout, HttpIce ice); + S create(WebRtc rtc, Peer peer, URI resource, String authorization, Duration timeout, HttpIce ice); } /** @@ -554,7 +555,7 @@ interface Factory { * * @return a future with the failure reported, or null. */ - private static CompletableFuture discard(WebRtc rtc, URI resource, String bearerToken, + private static CompletableFuture discard(WebRtc rtc, URI resource, String authorization, Duration timeout) { if (resource == null) { return CompletableFuture.completedFuture(null); @@ -563,7 +564,7 @@ private static CompletableFuture discard(WebRtc rtc, URI resour HttpCall request; try { - request = new HttpCall(rtc.httpClient(bearerToken != null), resource, "DELETE", bearerToken, timeout); + request = new HttpCall(rtc.httpClient(authorization != null), resource, "DELETE", authorization, timeout); } catch (RejectedExecutionException e) { return CompletableFuture.completedFuture(leftOnServer(rtc, resource)); @@ -617,7 +618,7 @@ private static WebRtcException leftOnServer(WebRtc rtc, URI resource) { /** * Resolves the resource the server named for a session. It may be on - * another host, as WHIP and WHEP allow, but the bearer token goes along to + * another host, as WHIP and WHEP allow, but the credentials go along to * delete it, so an https endpoint cannot name a plain http resource. */ static URI resourceOf(URI endpoint, String location) { @@ -708,6 +709,34 @@ static void requireContentType(HttpCall.Response response, String expected) { } } + /** + * @return the value of an Authorization header with a bearer token (RFC + * 6750), as WHIP and WHEP ask clients to support. + */ + static String bearer(String token) { + return "Bearer " + requireNonNull(token, "token"); + } + + /** + * @return the value of an Authorization header with Basic credentials (RFC + * 7617), in UTF-8. + * + * @throws IllegalArgumentException if the user contains a colon, which + * Basic cannot tell from the one that + * separates the password. + */ + static String basic(String user, String password) { + requireNonNull(user, "user"); + requireNonNull(password, "password"); + + if (user.indexOf(':') >= 0) { + throw new IllegalArgumentException("The user of Basic authentication must not contain a colon"); + } + + return "Basic " + Base64.getEncoder().encodeToString((user + ":" + password) + .getBytes(StandardCharsets.UTF_8)); + } + /** * Checks the codec names given to a builder. * diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java index a2b191525..a7b2c32b1 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/WebRtc.java @@ -605,22 +605,23 @@ private void shutdownPools() { } /** - * @param bearerToken whether the requests carry a bearer token. + * @param authorizes whether the requests carry credentials of their own, + * in the Authorization header. * * @return the client of WHIP and WHEP requests. * * @throws RejectedExecutionException once the runtime has stopped its * threads. */ - HttpClient httpClient(boolean bearerToken) { - int index = bearerToken ? 0 : 1; + HttpClient httpClient(boolean authorizes) { + int index = authorizes ? 0 : 1; synchronized (httpLock) { if (httpStopped) { throw new RejectedExecutionException("The runtime is closed"); } if (httpClients[index] == null) { - httpClients[index] = HttpCall.newClient(!bearerToken); + httpClients[index] = HttpCall.newClient(!authorizes); } return httpClients[index]; diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java index 7fe33bf16..c3242e3c8 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java @@ -80,8 +80,8 @@ public static Builder from(URI endpoint) { /** A stream received over WHEP. Closing it ends the session on the server. */ public static final class Session extends HttpSession { - Session(WebRtc rtc, Peer peer, URI resource, String bearerToken, Duration timeout, HttpIce ice) { - super(rtc, peer, resource, bearerToken, timeout, ice); + Session(WebRtc rtc, Peer peer, URI resource, String authorization, Duration timeout, HttpIce ice) { + super(rtc, peer, resource, authorization, timeout, ice); } } @@ -99,7 +99,8 @@ public static final class Builder { private final Map> codecs = new EnumMap<>(MediaKind.class); - private String bearerToken; + /** The value of the Authorization header, or null. */ + private String authorization; private Gathering gathering = Gathering.TRICKLE; @@ -112,15 +113,32 @@ private Builder(URI endpoint) { } /** - * @param token the token the endpoint authorizes the request with, - * sent as {@code Authorization: Bearer }. Servers - * that ask for a user and password, such as MediaMTX, - * take them this way as {@code user:password}. + * @param token the token the endpoint authorizes the requests with, + * sent as {@code Authorization: Bearer }. It + * replaces the credentials of {@link #basicAuth}. * * @return this builder. */ public Builder bearerToken(String token) { - this.bearerToken = requireNonNull(token, "token"); + this.authorization = HttpSession.bearer(token); + return this; + } + + /** + * Authorizes the requests with a user and password, by HTTP Basic + * authentication (RFC 7617), as servers such as MediaMTX take them. + * They go with every request of the session, as {@code + * Authorization: Basic}, in UTF-8, and replace a {@link #bearerToken}. + * + * @param user the user, without a colon. + * @param password the password. + * + * @return this builder. + * + * @throws IllegalArgumentException if the user contains a colon. + */ + public Builder basicAuth(String user, String password) { + this.authorization = HttpSession.basic(user, password); return this; } @@ -246,13 +264,13 @@ public CompletableFuture play(WebRtc rtc) { // The builder may change while the session starts; the session keeps // what it started with, so that its DELETE is authorized like its POST. - String token = bearerToken; + String credentials = authorization; Duration limit = timeout; Map> names = new EnumMap<>(codecs); PeerImpl peer = (PeerImpl) rtc.createPeer(options.build()); try { - return HttpSession.start(rtc, peer, endpoint, token, limit, peer.preferCodecs(names), gathering, + return HttpSession.start(rtc, peer, endpoint, credentials, limit, peer.preferCodecs(names), gathering, PROTOCOL, Session::new); } catch (RuntimeException | Error e) { diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java index 8c8ec2b98..af4c7cf1e 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java @@ -111,8 +111,8 @@ private static List encodings(List layers) { /** Tracks published over WHIP. Closing it ends the session on the server. */ public static final class Session extends HttpSession { - Session(WebRtc rtc, Peer peer, URI resource, String bearerToken, Duration timeout, HttpIce ice) { - super(rtc, peer, resource, bearerToken, timeout, ice); + Session(WebRtc rtc, Peer peer, URI resource, String authorization, Duration timeout, HttpIce ice) { + super(rtc, peer, resource, authorization, timeout, ice); } } @@ -130,7 +130,8 @@ public static final class Builder { private final Map> codecs = new EnumMap<>(MediaKind.class); - private String bearerToken; + /** The value of the Authorization header, or null. */ + private String authorization; private boolean alwaysOfferVideo; @@ -159,16 +160,33 @@ public Builder track(LocalTrack track) { } /** - * @param token the token the endpoint authorizes the request with, + * @param token the token the endpoint authorizes the requests with, * sent as {@code Authorization: Bearer }; for many - * services, the stream key. Servers that ask for a user - * and password, such as MediaMTX, take them this way as - * {@code user:password}. + * services, the stream key. It replaces the credentials + * of {@link #basicAuth}. * * @return this builder. */ public Builder bearerToken(String token) { - this.bearerToken = requireNonNull(token, "token"); + this.authorization = HttpSession.bearer(token); + return this; + } + + /** + * Authorizes the requests with a user and password, by HTTP Basic + * authentication (RFC 7617), as servers such as MediaMTX take them. + * They go with every request of the session, as {@code + * Authorization: Basic}, in UTF-8, and replace a {@link #bearerToken}. + * + * @param user the user, without a colon. + * @param password the password. + * + * @return this builder. + * + * @throws IllegalArgumentException if the user contains a colon. + */ + public Builder basicAuth(String user, String password) { + this.authorization = HttpSession.basic(user, password); return this; } @@ -354,7 +372,7 @@ public CompletableFuture publish(WebRtc rtc) { // The builder may change while the session starts; the session keeps // what it started with, so that its DELETE is authorized like its POST. - String token = bearerToken; + String credentials = authorization; Duration limit = timeout; Map> names = new EnumMap<>(codecs); boolean placeholder = alwaysOfferVideo && !kinds.contains(MediaKind.VIDEO); @@ -384,7 +402,7 @@ public CompletableFuture publish(WebRtc rtc) { CompletableFuture prepared = CompletableFuture.allOf(video, peer.preferCodecs(names)); - return HttpSession.start(rtc, peer, endpoint, token, limit, prepared, gathering, PROTOCOL, + return HttpSession.start(rtc, peer, endpoint, credentials, limit, prepared, gathering, PROTOCOL, Session::new); } catch (RuntimeException | Error e) { diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java index a6836d17b..9d5289014 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java @@ -29,11 +29,13 @@ import java.net.URI; import java.net.http.HttpTimeoutException; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -142,6 +144,48 @@ void whipPublishes() throws Exception { } } + @Test + void basicAuthSent() throws Exception { + // RFC 9725, 4.7: HTTP authentication besides bearer tokens, here Basic + // (RFC 7617) in UTF-8, on every request of the session. + String expected = "Basic " + Base64.getEncoder().encodeToString( + "user:pässword".getBytes(StandardCharsets.UTF_8)); + + try (TestHttpEndpoint endpoint = sendingEndpoint()) { + Whep.Session session = await(Whep.from(endpoint.uri()) + .bearerToken("replaced") + .basicAuth("user", "pässword") + .play(rtc)); + + endpoint.awaitRequest(r -> r.method.equals("PATCH"), "candidates", TIMEOUT_SECONDS); + await(session.closeAsync()); + + assertEquals(1, endpoint.count("DELETE /live/session/1")); + + for (TestHttpEndpoint.Request request : endpoint.requests) { + assertEquals(expected, request.authorization, request.toString()); + } + } + } + + @Test + void basicUserWithoutColon() throws Exception { + try (TestHttpEndpoint endpoint = receivingEndpoint()) { + assertThrows(IllegalArgumentException.class, () -> Whip.to(endpoint.uri()).basicAuth("us:er", "x")); + + // The credentials given last count. + Whip.Session session = await(Whip.to(endpoint.uri()) + .basicAuth("user", "password") + .bearerToken("secret") + .track(video) + .publish(rtc)); + + assertEquals("Bearer secret", endpoint.request("POST /live/endpoint").authorization); + + await(session.closeAsync()); + } + } + @Test void completeOffersNeedNoPatch() throws Exception { try (TestHttpEndpoint endpoint = sendingEndpoint()) {