Files
qbit-printing/dev/native-auth/java/NativeAuthHost.java
T
james.maesandCodex 3592decba8 feat(dev): add isolated Docker printing and provider qualification (#21)
* feat(dev): add isolated Docker printing and provider qualification

Provide owned local QQQ, MinIO and optional Authentik development fixtures with pinned inputs, protected synthetic state and ownership-checked cleanup. Exercise real Core/Java WebSocket jobs, S3 retention/isolation and opt-in native PKCE/provider renewal and revocation. Add cross-platform script gates and hosted Docker smoke CI.

Include the opt-in 10000-job pooled-SQL/reopen regression and scoped evidence. The experimental API1.9 source remains explicitly unreleased and separate from the released runtime dependency set.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

* fix(dev): report early workload failures before server waiting

Preserve bounded client diagnostics privately and emit only allowlisted stage and exception classifications. Early failed clients no longer hide behind the server completion timeout; ownership-checked cleanup remains in finally.

Five actual-entrypoint regression cases pass; all33script tests and parser/analyzer checks pass. This exposes the still-unknown hosted client failure without changing production behavior.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

* fix(dev): accept compatible realtime printing API minor versions

Use production Core discovery's strict stable SemVer validation and the realtime major2 compatibility gate instead of requiring exactly2.0.0. Protected main now advertises compatible2.1.0; the old fixture rejected it before login.

Pinned-Core build reproduced2.1.0 rejection before the fix and passes all9positive/negative HTTP discovery cases after it. Docker builds run these checks. No production client, server, dependency or runtime change.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Codex (GPT-6) <noreply@openai.com>
2026-11-01 10:12:08 -06:00

205 lines
11 KiB
Java

// SPDX-License-Identifier: Apache-2.0
package com.kingsrook.qbits.printing;
import java.net.URI;
import java.sql.Connection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2AuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.OAuth2NativeSessionLoginMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.RedirectStateMetaDataProducer;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionMetaDataProducer;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.metadata.UserSessionRefreshTokenMetaDataProducer;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSessionRefreshToken;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import io.javalin.http.Context;
import io.javalin.http.ForbiddenResponse;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* Opt-in container fixture for the pinned upstream native-session candidate.
* Standard OAuth2 code exchange, session storage and encrypted renewal are used
* unchanged. No installed pairing, password authentication double or printer.
*/
public final class NativeAuthHost extends AbstractQQQApplication
{
private static final String TENANT = "printing-native-dev";
private final String backendName = "printing-native-auth-" + UUID.randomUUID();
private final String jdbcUrl = "jdbc:h2:file:/data/printing-native-auth";
private final String databasePassword = required("NATIVE_QQQ_DATABASE_PASSWORD");
private final URI origin = httpsOrigin(required("NATIVE_QQQ_ORIGIN"));
private final int port = Integer.parseInt(required("NATIVE_QQQ_PORT"));
private final DriverDataSource source = new DriverDataSource(jdbcUrl, "sa", databasePassword);
private final PrintingService service = new PrintingService(source, TENANT);
private NativeAuthHost() throws Exception
{
if(port < 1024 || port > 65535)
{
throw new IllegalArgumentException("Unsupported internal fixture port");
}
service.initialize();
try(Connection connection = source.getConnection(); var statement = connection.createStatement())
{
statement.execute("CREATE TABLE IF NOT EXISTS userSession (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, uuid VARCHAR(40) NOT NULL UNIQUE, userId VARCHAR(100), accessToken CLOB, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)");
statement.execute("CREATE TABLE IF NOT EXISTS redirectState (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, state VARCHAR(45) NOT NULL UNIQUE, redirectUri VARCHAR(4096), createDate TIMESTAMP WITH TIME ZONE)");
statement.execute("CREATE TABLE IF NOT EXISTS userSessionRefreshToken (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, sessionUuid VARCHAR(40) NOT NULL UNIQUE, refreshToken CLOB, refreshTokenExpiresAt TIMESTAMP WITH TIME ZONE, sessionStartedAt TIMESTAMP WITH TIME ZONE, createDate TIMESTAMP WITH TIME ZONE, modifyDate TIMESTAMP WITH TIME ZONE)");
}
}
public static void main(String[] args) throws Exception
{
NativeAuthHost host = new NativeAuthHost();
QApplicationLauncher.run(host, new QApplicationLauncherConfig()
.withServerCustomizer(host::configure));
}
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = new QInstance();
instance.setFailOnMetaDataProducerError(true);
instance.setHomeAppName("printing");
instance.setBranding(new QBrandingMetaData().withAppName("QQQ native authentication fixture"));
instance.addBackend(new RDBMSBackendMetaData().withName(backendName).withVendor("h2").withHostName("fixture")
.withDatabaseName("printing-native-auth").withUsername("sa").withPassword(databasePassword).withJdbcUrl(jdbcUrl));
instance.addTable(new UserSessionMetaDataProducer(backendName).produce(instance));
instance.addTable(new RedirectStateMetaDataProducer(backendName).produce(instance));
instance.addTable(new UserSessionRefreshTokenMetaDataProducer(backendName).produce(instance));
String issuer = required("NATIVE_AUTH_ISSUER");
URI issuerUri = URI.create(issuer);
if(!"https".equals(issuerUri.getScheme()) || issuerUri.getHost() == null
|| issuerUri.getRawUserInfo() != null || issuerUri.getRawQuery() != null || issuerUri.getRawFragment() != null || !issuer.endsWith("/"))
{
throw new IllegalArgumentException("An explicit HTTPS fixture issuer ending in slash is required");
}
OAuth2AuthenticationMetaData authentication = new OAuth2AuthenticationMetaData()
.withBaseUrl(issuer).withExternalBaseUrl(issuer)
.withClientId(required("NATIVE_AUTH_CLIENT_ID")).withClientSecret(required("NATIVE_AUTH_CLIENT_SECRET"))
.withScopes("openid profile email offline_access")
.withUserSessionTableName(UserSession.TABLE_NAME).withRedirectStateTableName(RedirectStateMetaDataProducer.TABLE_NAME)
.withRefreshTokenTableName(UserSessionRefreshToken.TABLE_NAME)
.withRefreshTokensEnabled(true).withRefreshTokenSkewSeconds(0).withMaxSessionLifetimeSeconds(3600)
.withRefreshTokenEncryptionKey(required("NATIVE_QQQ_REFRESH_KEY")).withRefreshTokenEncryptionKeyId("fixture-1")
.withNativeSessionLogin(new OAuth2NativeSessionLoginMetaData().withIssuer(issuer)
.withDiscoveryUrl(issuer + ".well-known/openid-configuration")
.withRedirectUris(List.of(required("NATIVE_AUTH_REDIRECT_URI"))));
authentication.withName("printing-native-provider").withSessionStoreEnabled(false)
.withCustomizer(new QCodeReference(PrintingPermissions.class));
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), authentication);
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(backendName).withService(service))
.produce(instance, "printing");
for(QTableMetaData table : instance.getTables().values())
{
if(backendName.equals(table.getBackendName()))
{
table.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName().toUpperCase(Locale.ROOT)));
for(var field : table.getFields().values())
{
field.withBackendName(field.getName().toUpperCase(Locale.ROOT));
}
}
}
return instance;
}
private void configure(QApplicationJavalinServer server)
{
// Only the owned TLS proxy is published; this HTTP listener remains inside
// the Compose network. Forwarded HTTPS is required even for discovery.
server.withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
.withAdditionalRouteProviders(List.of(new PrintingRealtimeRoutes(service)))
.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "0.0.0.0";
config.http.maxRequestSize = 1048576;
config.routes.before(this::guardOrigin);
});
}
private void guardOrigin(Context context)
{
if(!origin.getRawAuthority().equalsIgnoreCase(context.header("Host"))
|| !"https".equals(context.header("X-Forwarded-Proto")))
{
throw new ForbiddenResponse("The fixture requires its configured HTTPS proxy origin");
}
String suppliedOrigin = context.header("Origin");
if(suppliedOrigin != null && !origin.toString().equals(suppliedOrigin))
{
throw new ForbiddenResponse("Cross-origin fixture requests are not allowed");
}
if("cross-site".equalsIgnoreCase(context.header("Sec-Fetch-Site"))
|| context.header("X-QQQ-In-Process-Session") != null)
{
throw new ForbiddenResponse("Only standard native session authentication is allowed");
}
boolean unsafe = !Set.of("GET", "HEAD", "OPTIONS").contains(context.method().name());
boolean cookie = context.cookie("sessionUUID") != null || context.cookie("sessionId") != null;
if(unsafe && cookie && suppliedOrigin == null)
{
throw new ForbiddenResponse("Cookie mutations require the configured Origin");
}
}
/** Only maps claims after the unchanged standard OAuth2 module authenticates. */
public static final class PrintingPermissions implements QAuthenticationModuleCustomizerInterface
{
@Override
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
{
session.setPermissions(Set.of());
session.withSecurityKeyValue("printing_tenant", TENANT);
Object claims = context.get("jwtPayloadJsonObject");
if(session.getUser() != null && claims instanceof JSONObject payload)
{
JSONArray groups = payload.optJSONArray("groups");
if(groups != null && groups.toList().contains("printing_admin"))
{
session.setPermissions(Set.of("printing_admin"));
}
}
}
}
private static URI httpsOrigin(String value)
{
URI parsed = URI.create(value);
if(!"https".equals(parsed.getScheme()) || parsed.getHost() == null || parsed.getRawUserInfo() != null
|| parsed.getRawQuery() != null || parsed.getRawFragment() != null || !parsed.getRawPath().isEmpty())
{
throw new IllegalArgumentException("A root HTTPS fixture origin without trailing slash is required");
}
return parsed;
}
private static String required(String name)
{
String value = System.getenv(name);
if(value == null || value.isBlank())
{
throw new IllegalArgumentException("Set the synthetic fixture variable " + name);
}
return value;
}
}