Skip to content

Commit a360d93

Browse files
committed
JVMCBC-1689 Add JwtAuthenticator
Modifications ------------- Add SaslMechanism.OAUTHBEARER, and modify SaslMechanism.from(String) to use a lookup table that gets populated automatically. (SASL mechanism names are case-sensitive!) Add InitialResponseSaslClient, an abstract base class for single-step SASL mechanisms (like PLAIN and OAUTHBEARER). Move common code from ScramSaslClient into CallbackHelper. Add OauthBearerSaslClient, a subclass of InitialResponseSaslClient that sends the username and JWT in the expected format. Change-Id: I4fe89131e8616dad5a7bd4b823612ccba86a871b Reviewed-on: https://review.couchbase.org/c/couchbase-jvm-clients/+/233969 Reviewed-by: Michael Reiche <[email protected]> Tested-by: Build Bot <[email protected]>
1 parent cf17ae2 commit a360d93

File tree

8 files changed

+408
-48
lines changed

8 files changed

+408
-48
lines changed

core-io/src/main/java/com/couchbase/client/core/env/Authenticator.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
* Implementing this interface yourself is not recommended. Please use one of the provided implementations.
3333
*
3434
* @see PasswordAuthenticator
35+
* @see JwtAuthenticator
3536
* @see CertificateAuthenticator
3637
* @see DelegatingAuthenticator
3738
*
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright 2025 Couchbase, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.couchbase.client.core.env;
18+
19+
import com.couchbase.client.core.annotation.SinceCouchbase;
20+
import com.couchbase.client.core.deps.com.fasterxml.jackson.databind.node.ObjectNode;
21+
import com.couchbase.client.core.deps.io.netty.channel.ChannelPipeline;
22+
import com.couchbase.client.core.endpoint.EndpointContext;
23+
import com.couchbase.client.core.io.netty.kv.SaslAuthenticationHandler;
24+
import com.couchbase.client.core.io.netty.kv.sasl.OauthBearerSaslClient;
25+
import com.couchbase.client.core.json.Mapper;
26+
import org.jspecify.annotations.NullMarked;
27+
28+
import java.time.Duration;
29+
import java.time.Instant;
30+
import java.util.Arrays;
31+
import java.util.Base64;
32+
import java.util.EnumSet;
33+
import java.util.List;
34+
import java.util.Set;
35+
36+
import static com.couchbase.client.core.logging.RedactableArgument.redactUser;
37+
import static java.util.Collections.unmodifiableSet;
38+
import static java.util.Objects.requireNonNull;
39+
import static java.util.stream.Collectors.toList;
40+
41+
/**
42+
* Authenticates with the Couchbase Server cluster using a JSON Web Token (JWT)
43+
* issued by an Identity Provider.
44+
* <p>
45+
* Create a new instance by calling {@link JwtAuthenticator#create(String)}.
46+
*/
47+
@SinceCouchbase("8.1")
48+
@NullMarked
49+
public class JwtAuthenticator implements Authenticator {
50+
/**
51+
* @implNote The SASL exchange is handled by {@link OauthBearerSaslClient}
52+
*/
53+
private static final Set<SaslMechanism> supportedMechanisms =
54+
unmodifiableSet(EnumSet.of(SaslMechanism.OAUTHBEARER));
55+
56+
public static JwtAuthenticator create(String jwt) {
57+
return new JwtAuthenticator(jwt);
58+
}
59+
60+
private static class Jwt {
61+
private final ObjectNode header;
62+
private final ObjectNode payload;
63+
64+
private Jwt(ObjectNode header, ObjectNode payload) {
65+
this.header = requireNonNull(header);
66+
this.payload = requireNonNull(payload);
67+
}
68+
69+
public static Jwt parse(String jwt) {
70+
String[] parts = jwt.split("\\.");
71+
if (parts.length != 3) {
72+
throw new IllegalArgumentException("Expected JWT to have 3 dot-separated components, but found " + parts.length);
73+
}
74+
75+
List<byte[]> decodedParts = Arrays.stream(parts)
76+
.map(Jwt::decodeBas64Url)
77+
.collect(toList());
78+
79+
try {
80+
return new Jwt(
81+
(ObjectNode) Mapper.decodeIntoTree(decodedParts.get(0)),
82+
(ObjectNode) Mapper.decodeIntoTree(decodedParts.get(1))
83+
);
84+
} catch (Exception e) {
85+
throw new IllegalArgumentException("Malformed JWT; not JSON", e);
86+
}
87+
}
88+
89+
private static byte[] decodeBas64Url(String s) {
90+
try {
91+
return Base64.getUrlDecoder().decode(s);
92+
} catch (Exception e) {
93+
throw new IllegalArgumentException("Malformed JWT; component not Base64URL-encoded", e);
94+
}
95+
}
96+
97+
@Override
98+
public String toString() {
99+
long expiryEpochSeconds = payload.path("exp").longValue();
100+
Duration timeUntilExpiry = Duration.ofSeconds(expiryEpochSeconds - Instant.now().getEpochSecond());
101+
return "Jwt{" +
102+
"header=" + redactUser(header) +
103+
", payload=" + redactUser(payload) +
104+
", signature=<redacted>" + // because we don't want a valid JWT to appear in any logs.
105+
", timeUntilExpiry=" + timeUntilExpiry +
106+
'}';
107+
}
108+
}
109+
110+
private final Jwt jwt;
111+
private final String encodedJwt;
112+
private final String authHeaderValue;
113+
private final String username;
114+
115+
private JwtAuthenticator(String encodedJwt) {
116+
this.encodedJwt = encodedJwt.trim();
117+
this.authHeaderValue = "Bearer " + this.encodedJwt;
118+
this.jwt = Jwt.parse(this.encodedJwt);
119+
120+
String fieldName = "sub";
121+
this.username = jwt.payload.path(fieldName).textValue();
122+
if (this.username == null) {
123+
throw new IllegalArgumentException("Missing '" + fieldName + "' in JWT payload");
124+
}
125+
}
126+
127+
@Override
128+
public void authKeyValueConnection(EndpointContext ctx, ChannelPipeline pipeline) {
129+
pipeline.addLast(new SaslAuthenticationHandler(
130+
ctx,
131+
username,
132+
encodedJwt,
133+
supportedMechanisms
134+
));
135+
}
136+
137+
@Override
138+
public String getAuthHeaderValue() {
139+
return authHeaderValue;
140+
}
141+
142+
@Override
143+
public String toString() {
144+
// Omit sensitive properties (like authHeaderValue and encodedJwt)
145+
// so they don't accidentally end up in a log.
146+
return "JwtAuthenticator{" +
147+
"jwt=" + jwt +
148+
'}';
149+
}
150+
}

core-io/src/main/java/com/couchbase/client/core/env/SaslMechanism.java

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616

1717
package com.couchbase.client.core.env;
1818

19+
import org.jspecify.annotations.Nullable;
20+
21+
import java.util.Arrays;
22+
import java.util.Map;
23+
24+
import static java.util.Collections.unmodifiableMap;
25+
import static java.util.Objects.requireNonNull;
26+
import static java.util.stream.Collectors.toMap;
27+
1928
/**
2029
* Describes the support SASL authentication mechanisms.
2130
*/
@@ -24,13 +33,20 @@ public enum SaslMechanism {
2433
PLAIN("PLAIN", 1),
2534
SCRAM_SHA1("SCRAM-SHA1", 2),
2635
SCRAM_SHA256("SCRAM-SHA256", 2),
27-
SCRAM_SHA512("SCRAM-SHA512", 2);
36+
SCRAM_SHA512("SCRAM-SHA512", 2),
37+
OAUTHBEARER("OAUTHBEARER", 1),
38+
;
2839

2940
private final String mech;
3041
private final int roundtrips;
3142

43+
private static final Map<String, SaslMechanism> lookupTable = unmodifiableMap(
44+
Arrays.stream(SaslMechanism.values())
45+
.collect(toMap(it -> it.mech, it -> it))
46+
);
47+
3248
SaslMechanism(String mech, int roundtrips) {
33-
this.mech = mech;
49+
this.mech = requireNonNull(mech);
3450
this.roundtrips = roundtrips;
3551
}
3652

@@ -54,17 +70,7 @@ public int roundtrips() {
5470
* @param mech the mechanism to convert.
5571
* @return null if not found, otherwise the enum representation.
5672
*/
57-
public static SaslMechanism from(final String mech) {
58-
if (mech.equalsIgnoreCase("PLAIN")) {
59-
return PLAIN;
60-
} else if (mech.equalsIgnoreCase("SCRAM-SHA1")) {
61-
return SCRAM_SHA1;
62-
} else if (mech.equalsIgnoreCase("SCRAM-SHA256")) {
63-
return SCRAM_SHA256;
64-
} else if (mech.equalsIgnoreCase("SCRAM-SHA512")) {
65-
return SCRAM_SHA512;
66-
}
67-
68-
return null;
73+
public static @Nullable SaslMechanism from(final String mech) {
74+
return lookupTable.get(mech);
6975
}
7076
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2025 Couchbase, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.couchbase.client.core.io.netty.kv.sasl;
18+
19+
import com.couchbase.client.core.annotation.Stability;
20+
21+
import javax.security.auth.callback.Callback;
22+
import javax.security.auth.callback.CallbackHandler;
23+
import javax.security.auth.callback.NameCallback;
24+
import javax.security.auth.callback.PasswordCallback;
25+
import javax.security.auth.callback.UnsupportedCallbackException;
26+
import javax.security.sasl.SaslException;
27+
import java.io.IOException;
28+
import java.util.Arrays;
29+
30+
@Stability.Internal
31+
class CallbackHelper {
32+
private CallbackHelper() {
33+
throw new AssertionError("not instantiable");
34+
}
35+
36+
static String getUsername(CallbackHandler callbackHandler) throws SaslException {
37+
final NameCallback nameCallback = new NameCallback("Username");
38+
try {
39+
callbackHandler.handle(new Callback[]{nameCallback});
40+
} catch (IOException | UnsupportedCallbackException e) {
41+
throw new SaslException("Missing callback fetch username", e);
42+
}
43+
44+
final String name = nameCallback.getName();
45+
if (name == null || name.isEmpty()) {
46+
throw new SaslException("Missing username");
47+
}
48+
return name;
49+
}
50+
51+
static String getPassword(CallbackHandler callbackHandler) throws SaslException {
52+
final PasswordCallback passwordCallback = new PasswordCallback("Password", false);
53+
try {
54+
try {
55+
callbackHandler.handle(new Callback[]{passwordCallback});
56+
} catch (IOException | UnsupportedCallbackException e) {
57+
throw new SaslException("Missing callback fetch password", e);
58+
}
59+
60+
final char[] pw = passwordCallback.getPassword();
61+
if (pw == null) {
62+
throw new SaslException("Password can't be null");
63+
}
64+
65+
String result = new String(pw);
66+
Arrays.fill(pw, ' ');
67+
return result;
68+
69+
} finally {
70+
passwordCallback.clearPassword();
71+
}
72+
}
73+
}

core-io/src/main/java/com/couchbase/client/core/io/netty/kv/sasl/CouchbaseSaslClientFactory.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
1616

1717
package com.couchbase.client.core.io.netty.kv.sasl;
1818

19+
import com.couchbase.client.core.env.SaslMechanism;
20+
1921
import javax.security.auth.callback.CallbackHandler;
2022
import javax.security.sasl.Sasl;
2123
import javax.security.sasl.SaslClient;
2224
import javax.security.sasl.SaslClientFactory;
2325
import javax.security.sasl.SaslException;
26+
import java.util.Arrays;
27+
import java.util.List;
2428
import java.util.Map;
2529

2630
/**
@@ -45,10 +49,17 @@ public SaslClient createSaslClient(final String[] mechanisms, final String autho
4549

4650
SaslClient client = SCRAM_FACTORY
4751
.createSaslClient(mechanisms, authorizationId, protocol, serverName, props, cbh);
48-
if (client == null) {
49-
client = Sasl.createSaslClient(mechanisms, authorizationId, protocol, serverName, props, cbh);
52+
53+
if (client != null) {
54+
return client;
5055
}
51-
return client;
56+
57+
List<String> mechanismList = Arrays.asList(mechanisms);
58+
if (mechanismList.contains(SaslMechanism.OAUTHBEARER.mech())) {
59+
return new OauthBearerSaslClient(cbh);
60+
}
61+
62+
return Sasl.createSaslClient(mechanisms, authorizationId, protocol, serverName, props, cbh);
5263
}
5364

5465
/**

0 commit comments

Comments
 (0)