Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
* meaning somewhere in the auth transport chain:
*
* <ul>
* <li>{@code |} — joins tokens in the JDBC {@code auths} extra credential;</li>
* <li>{@code ,} — joins tokens in the {@code is_visible} SQL literal (and splits them
* again inside the UDF), and delimits auth-mapping file lists;</li>
* again inside the UDF), splits the decoded {@code auths} credential payload,
* and delimits auth-mapping file lists;</li>
* <li>{@code ;} / {@code :} — delimit the Trino JDBC {@code extraCredentials}
* {@code name:value;name:value} wire encoding;</li>
* <li>whitespace and non-printable/non-ASCII — rejected by the Trino JDBC driver's
Expand Down Expand Up @@ -45,7 +45,7 @@ static boolean isValid(String token) {
}
for (int i = 0; i < token.length(); i++) {
char c = token.charAt(i);
if (c <= ' ' || c > '~' || c == ',' || c == '|' || c == ';' || c == ':') {
if (c <= ' ' || c > '~' || c == ',' || c == ';' || c == ':') {
return false;
}
}
Expand All @@ -62,8 +62,7 @@ static void validate(Collection<String> auths) {
if (!isValid(token)) {
throw new IllegalArgumentException(
"Invalid authorization token '" + token + "': tokens must be non-empty printable"
+ " ASCII without ',', '|', ';', ':', or whitespace (structural delimiters in the"
+ " auth transport)");
+ " ASCII without ',', ';', ':', or whitespace");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.locationtech.geomesa.security.AuthorizationsProvider;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -105,18 +106,26 @@ Connection connect(List<String> auths) throws SQLException {
*
* <p>The Trino JDBC {@code extraCredentials} property is {@code name:value}
* pairs delimited by SEMICOLONS (NOT commas), and each value must be printable
* ASCII with no spaces. So auth tokens are joined with PIPES and the secret is
* a second pair: {@code auths:basic|privileged;secret:<value>}. Throws
* {@code IllegalArgumentException} on a token containing a transport delimiter —
* the server-side resolver would re-split it into auths that were never issued
* (see {@link AuthTokens}). */
* ASCII with no spaces. The auth tokens are comma-joined and then base64url
* encoded (unpadded) into a single value — the base64url alphabet can never
* collide with the wire delimiters, so the transport is injection-proof by
* construction: {@code auths:<base64url(tok1,tok2)>;secret:<value>}. The
* server-side {@code ExtraCredentialAuthorizationResolver} decodes the same
* format.
*
* <p>Tokens are still validated against {@link AuthTokens} before encoding:
* the SQL row-filter layer joins tokens with commas into the
* {@code is_visible} literal, so the no-delimiter token contract remains
* load-bearing there even though the wire itself no longer requires it. */
static Properties connectionProperties(String user, List<String> auths, String secret) {
AuthTokens.validate(auths);
Properties props = new Properties();
props.setProperty("user", user);
StringBuilder creds = new StringBuilder();
if (auths != null && !auths.isEmpty()) {
creds.append(AUTHS_CREDENTIAL).append(':').append(String.join("|", auths));
String encoded = Base64.getUrlEncoder().withoutPadding()
.encodeToString(String.join(",", auths).getBytes(StandardCharsets.UTF_8));
creds.append(AUTHS_CREDENTIAL).append(':').append(encoded);
}
if (secret != null && !secret.isEmpty()) {
if (creds.length() > 0) creds.append(';'); // semicolon delimits pairs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,22 @@ void emptyAuthsSendNoCredential() {
}

@Test
void authsBecomePipeDelimitedExtraCredential() {
// Pipe-delimited, NOT space/comma: the Trino JDBC extraCredentials value
// forbids spaces and uses comma/colon as structural separators. The
// resolver splits the value on pipes, commas, or whitespace.
void authsBecomeBase64UrlExtraCredential() {
Properties props = TrinoDataStore.connectionProperties("svc", List.of("basic", "privileged"), null);
assertThat(props.getProperty("extraCredentials")).isEqualTo("auths:basic|privileged");
assertThat(props.getProperty("extraCredentials")).isEqualTo("auths:YmFzaWMscHJpdmlsZWdlZA");
}

@Test
void singleAuthEncodes() {
Properties props = TrinoDataStore.connectionProperties("svc", List.of("basic"), null);
assertThat(props.getProperty("extraCredentials")).isEqualTo("auths:basic");
assertThat(props.getProperty("extraCredentials")).isEqualTo("auths:YmFzaWM");
}

@Test
void secretAddedAsSecondPairDelimitedBySemicolon() {
// Trino JDBC extraCredentials delimits name:value pairs with SEMICOLONS.
Properties props = TrinoDataStore.connectionProperties("svc", List.of("basic", "privileged"), "tok3n");
assertThat(props.getProperty("extraCredentials")).isEqualTo("auths:basic|privileged;secret:tok3n");
assertThat(props.getProperty("extraCredentials")).isEqualTo("auths:YmFzaWMscHJpdmlsZWdlZA;secret:tok3n");
}

@Test
Expand All @@ -68,11 +65,8 @@ void secretSentEvenWithoutAuths() {
}

@Test
void authTokenContainingTransportDelimiterIsRejected() {
// '|' joins tokens in the credential value, ';'/':' delimit its pairs, and the
// server-side resolver splits on pipes/commas/whitespace — a token containing
// any of them would be silently re-split into auths that were never issued.
for (String bad : List.of("FOO,BAR", "FOO|BAR", "FOO;BAR", "FOO:BAR", "FOO BAR", "")) {
void authTokenContainingDelimiterIsRejected() {
for (String bad : List.of("FOO,BAR", "FOO BAR", "FOO;BAR", "FOO:BAR", "FOO BAR", "")) {
assertThatThrownBy(() ->
TrinoDataStore.connectionProperties("svc", List.of("basic", bad), null))
.isInstanceOf(IllegalArgumentException.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ void combineBothWrapsFilterAndAnds() {

@Test
void authTokenContainingTransportDelimiterIsRejected() {
for (String bad : List.of("FOO,BAR", "FOO|BAR", "FOO;BAR", "FOO:BAR", "FOO BAR", "")) {
for (String bad : List.of("FOO,BAR", "FOO;BAR", "FOO:BAR", "FOO BAR", "")) {
assertThatThrownBy(() ->
TrinoFeatureSource.visibilityConjunct("__vis__", List.of("basic", bad)))
.isInstanceOf(IllegalArgumentException.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
* meaning somewhere in the auth transport chain:
*
* <ul>
* <li>{@code |} — joins tokens in the JDBC {@code auths} extra credential;</li>
* <li>{@code ,} — joins tokens in the {@code is_visible} SQL literal (and splits them
* again inside the UDF), and delimits auth-mapping file lists;</li>
* again inside the UDF), splits the decoded {@code auths} credential payload,
* and delimits auth-mapping file lists;</li>
* <li>{@code ;} / {@code :} — delimit the Trino JDBC {@code extraCredentials}
* {@code name:value;name:value} wire encoding;</li>
* <li>whitespace and non-printable/non-ASCII — rejected by the Trino JDBC driver's
Expand Down Expand Up @@ -45,7 +45,7 @@ static boolean isValid(String token) {
}
for (int i = 0; i < token.length(); i++) {
char c = token.charAt(i);
if (c <= ' ' || c > '~' || c == ',' || c == '|' || c == ';' || c == ':') {
if (c <= ' ' || c > '~' || c == ',' || c == ';' || c == ':') {
return false;
}
}
Expand All @@ -62,7 +62,7 @@ static void validate(Collection<String> auths) {
if (!isValid(token)) {
throw new IllegalArgumentException(
"Invalid authorization token '" + token + "': tokens must be non-empty printable"
+ " ASCII without ',', '|', ';', ':', or whitespace (structural delimiters in the"
+ " ASCII without ',', ';', ':', or whitespace (structural delimiters in the"
+ " auth transport)");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
Expand All @@ -20,12 +21,14 @@

/**
* {@link AuthorizationResolver} that reads a session's authorization tokens
* straight from a Trino <em>extra credential</em> carrying a comma-delimited
* list (e.g. {@code auths=basic,privileged}). Intended for deployments fronted by a
* trusted service mesh that authenticates the caller and injects the
* authoritative token set: the mesh rewrites its identity header (e.g.
* {@code x-auths}) into Trino's {@code X-Trino-Extra-Credential} client
* header, which Trino exposes here via {@link ConnectorIdentity#getExtraCredentials()}.
* from a Trino <em>extra credential</em> carrying a base64url-encoded
* token list. The decoded payload is a comma or whitespace delimited list.
* Base64url keeps the wire value free of the extraCredentials pair delimiters
* by construction. Intended for deployments fronted by a trusted service mesh
* that authenticates the caller and injects the authoritative token set:
* the mesh base64url-encodes its identity header (e.g. {@code x-auths}) into Trino's
* {@code X-Trino-Extra-Credential} client header, which Trino exposes here
* via {@link ConnectorIdentity#getExtraCredentials()}.
*
* <p>Because the tokens flow through unchanged there is no mapping file to
* maintain and nothing to drift from the platform's clearances. Selected with:
Expand Down Expand Up @@ -102,12 +105,16 @@ private boolean secretMatches(ConnectorIdentity identity) {
return MessageDigest.isEqual(expectedSecret, presented.getBytes(StandardCharsets.UTF_8));
}

private static void addTokens(Set<String> out, String csv) {
if (csv == null) return;
// Split on pipes, commas, or whitespace. The JDBC datastore joins tokens
// with pipes (extraCredentials values forbid spaces); a mesh header may use
// a comma or space list. Accept all three.
for (String token : csv.split("[\\s,|]+")) {
private static void addTokens(Set<String> out, String encoded) {
if (encoded == null) return;
String decoded;
try {
decoded = new String(Base64.getUrlDecoder().decode(encoded.trim()), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
LOG.warn("Authorization extra credential is not valid base64url; resolving to no auths");
return;
}
for (String token : decoded.split("[\\s,]+")) {
String t = token.trim();
if (t.isEmpty()) continue;
if (!AuthTokens.isValid(t)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
import io.trino.spi.security.ConnectorIdentity;
import org.junit.jupiter.api.Test;

import java.util.Base64;

import java.nio.charset.StandardCharsets;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -25,67 +28,63 @@ private static ExtraCredentialAuthorizationResolver resolver(Map<String, String>
return new ExtraCredentialAuthorizationResolver(config);
}

private static String enc(String tokenList) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(tokenList.getBytes(StandardCharsets.UTF_8));
}

@Test
void readsTokensFromDefaultCredential() {
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", "basic,privileged"))))
assertThat(r.authorizationsFor(identity(Map.of("auths", enc("basic,privileged")))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void honorsConfiguredCredentialName() {
var r = resolver(Map.of(ExtraCredentialAuthorizationResolver.CREDENTIAL_KEY, "x-auths"));
assertThat(r.authorizationsFor(identity(Map.of("x-auths", "basic,privileged"))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void readsPipeDelimitedTokens() {
// The JDBC datastore joins tokens with pipes (spaces are rejected by the
// Trino JDBC extraCredentials value validation).
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", "basic|privileged"))))
assertThat(r.authorizationsFor(identity(Map.of("x-auths", enc("basic,privileged")))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void readsSpaceOrCommaDelimitedTokens() {
// Robust to a mesh-injected header carrying a space or comma list too.
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", "basic, privileged"))))
assertThat(r.authorizationsFor(identity(Map.of("auths", enc("basic, privileged")))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void whitespaceAndBlankTokensAreTrimmedAndDropped() {
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", " basic , privileged ,,"))))
assertThat(r.authorizationsFor(identity(Map.of("auths", enc(" basic , privileged ,,")))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void missingCredentialFailsClosedEmpty() {
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("other", "basic,privileged")))).isEmpty();
assertThat(r.authorizationsFor(identity(Map.of("other", enc("basic,privileged"))))).isEmpty();
}

@Test
void secretGateHonorsAuthsWhenSecretMatches() {
var r = resolver(Map.of(ExtraCredentialAuthorizationResolver.SECRET_KEY, "s3cr3t"));
assertThat(r.authorizationsFor(identity(Map.of("secret", "s3cr3t", "auths", "basic privileged"))))
assertThat(r.authorizationsFor(identity(Map.of("secret", "s3cr3t", "auths", enc("basic privileged")))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void secretGateFailsClosedWhenSecretWrong() {
var r = resolver(Map.of(ExtraCredentialAuthorizationResolver.SECRET_KEY, "s3cr3t"));
assertThat(r.authorizationsFor(identity(Map.of("secret", "nope", "auths", "basic privileged")))).isEmpty();
assertThat(r.authorizationsFor(identity(Map.of("secret", "nope", "auths", enc("basic privileged"))))).isEmpty();
}

@Test
void secretGateFailsClosedWhenSecretMissing() {
var r = resolver(Map.of(ExtraCredentialAuthorizationResolver.SECRET_KEY, "s3cr3t"));
assertThat(r.authorizationsFor(identity(Map.of("auths", "basic privileged")))).isEmpty();
assertThat(r.authorizationsFor(identity(Map.of("auths", enc("basic privileged"))))).isEmpty();
}

@Test
Expand All @@ -94,13 +93,23 @@ void emptyCredentialFailsClosedEmpty() {
assertThat(r.authorizationsFor(identity(Map.of("auths", "")))).isEmpty();
}

@Test
void pinnedWireEncodingDecodesToTokens() {
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", "YmFzaWMscHJpdmlsZWdlZA"))))
.containsExactlyInAnyOrder("basic", "privileged");
}

@Test
void nonBase64CredentialFailsClosedEmpty() {
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", "basic,privileged")))).isEmpty();
}

@Test
void tokensCarryingPairDelimitersAreDroppedFailClosed() {
// ';'/':' survive the pipe/comma/whitespace split but delimit the extraCredentials
// wire pairs — no legitimate producer can grant such a token (AuthTokens rejects
// them at the source), so the resolver drops rather than honors it.
var r = resolver(Map.of());
assertThat(r.authorizationsFor(identity(Map.of("auths", "basic|FOO:BAR|privileged"))))
assertThat(r.authorizationsFor(identity(Map.of("auths", enc("basic,FOO:BAR,privileged")))))
.containsExactlyInAnyOrder("basic", "privileged");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ void whitespaceAndBlankTokensAreTrimmedAndDropped(@TempDir Path dir) throws Exce

@Test
void invalidTokensAreDroppedFailClosed(@TempDir Path dir) throws Exception {
// A token carrying a transport delimiter ('|' here, or ':'/';'/interior space)
// A token carrying a transport delimiter (':'/';'/interior space)
// can't round-trip through the row-filter chain — dropped with a warning
// (narrowing = fail-closed) rather than honored.
Path f = dir.resolve("m.properties");
Files.write(f, java.util.List.of("user.alice=basic,FOO|BAR,privileged"));
Files.write(f, java.util.List.of("user.alice=basic,FOO;BAR,privileged"));
var resolver = new FileAuthorizationResolver(f);
assertThat(resolver.authorizationsFor(identity("alice", Set.of())))
.containsExactlyInAnyOrder("basic", "privileged");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ void doubleQuoteInColumnIsDoubled() {

@Test
void authTokenContainingTransportDelimiterIsRejected() {
for (String bad : List.of("FOO,BAR", "FOO|BAR", "FOO;BAR", "FOO:BAR", "FOO BAR", "")) {
for (String bad : List.of("FOO,BAR", "FOO;BAR", "FOO:BAR", "FOO BAR", "")) {
assertThatThrownBy(() -> VisibilityRowFilter.conjunct("__vis__", List.of("basic", bad)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authorization token");
Expand Down
Loading