/* * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2026. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ * * 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 * * https://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. */ // 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 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; } }