Skip to content
Merged
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
Empty file modified sources/mvnw
100644 → 100755
Empty file.
2 changes: 1 addition & 1 deletion sources/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.solutions</groupId>
<artifactId>jitaccess</artifactId>
<version>2.3.0-wavemm.13</version>
<version>2.3.0-wavemm.14</version>
<properties>
<surefire-plugin.version>3.5.3</surefire-plugin.version>
<surefire-plugin.version>3.5.3</surefire-plugin.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ public GoogleCredentials produceApplicationCredentials() {

@Produces
public @NotNull LinkBuilder produceLinkBuilder() {
return uriInfo -> uriInfo
.getBaseUriBuilder()
.scheme(runtime.type() == ApplicationRuntime.Type.DEVELOPMENT
return new CanonicalLinkBuilder(
configuration.publicBaseUrl.orElse(null),
runtime.type() == ApplicationRuntime.Type.DEVELOPMENT
? "http"
: "https");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import com.google.solutions.jitaccess.apis.OrganizationId;
import org.jetbrains.annotations.NotNull;

import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZoneOffset;
Expand Down Expand Up @@ -221,6 +223,17 @@ public class ApplicationConfiguration extends AbstractConfiguration {
*/
final boolean slackCopyLinkEnabled;

/**
* Canonical base URL for generated links, e.g. the approval links
* fanned out to reviewers (wavemm fork). When set, links built from
* requests that arrived on the app's default appspot host are
* rewritten to this URL. Versioned (…-dot-…) staging hosts, custom
* domains, and localhost are never rewritten — see
* {@link CanonicalLinkBuilder}. Optional; when unset, links inherit
* the requesting user's host (upstream behaviour).
*/
final @NotNull Optional<URI> publicBaseUrl;

public ApplicationConfiguration(@NotNull Map<String, String> settingsData) {
super(settingsData);

Expand Down Expand Up @@ -352,6 +365,24 @@ public ApplicationConfiguration(@NotNull Map<String, String> settingsData) {
this.slackCopyLinkEnabled = readSetting(
Boolean::parseBoolean, "SLACK_COPY_LINK_ENABLED")
.orElse(false);
this.publicBaseUrl = readStringSetting("PUBLIC_BASE_URL")
.map(url -> {
try {
var uri = new URI(url);
if (uri.getHost() == null ||
!("https".equalsIgnoreCase(uri.getScheme()) ||
"http".equalsIgnoreCase(uri.getScheme()))) {
throw new URISyntaxException(url, "not an absolute http(s) URL");
}
return uri;
}
catch (URISyntaxException e) {
throw new IllegalStateException(
"The environment variable 'PUBLIC_BASE_URL' must contain an " +
"absolute http(s) URL",
e);
}
});
}

public boolean isSmtpConfigured() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// Copyright 2026 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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
//
// http://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.
//

package com.google.solutions.jitaccess.web;

import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.net.URI;
import java.util.Locale;

/**
* Wavemm fork: link builder that rewrites generated links to a canonical
* public URL when — and only when — the request arrived on the app's
* default appspot host.
*
* <p>Generated links (e.g. the approval link fanned out to reviewers in
* Slack DMs) otherwise inherit whatever host the requester was browsing:
* a request submitted on the default appspot host sends every reviewer
* an appspot link even though the app has a custom domain.
*
* <p>Versioned App Engine hosts (rev-…-dot-…appspot.com) are deliberately
* left untouched: they are the staging surface (deploys with
* promote_traffic=false are smoke-tested on the versioned URL, see
* SLACK_INTEGRATION.md in wavemm-iam), and rewriting them would point
* staging-generated links at the traffic-promoted production version.
* Custom domains and localhost pass through unchanged, too.
*/
public class CanonicalLinkBuilder implements LinkBuilder {
private final @Nullable URI canonicalBaseUri;
private final @NotNull String fallbackScheme;

public CanonicalLinkBuilder(
@Nullable URI canonicalBaseUri,
@NotNull String fallbackScheme
) {
this.canonicalBaseUri = canonicalBaseUri;
this.fallbackScheme = fallbackScheme;
}

@Override
public UriBuilder absoluteUriBuilder(@NotNull UriInfo uriInfo) {
if (this.canonicalBaseUri != null &&
isDefaultAppspotHost(uriInfo.getBaseUri().getHost())) {
return UriBuilder.fromUri(this.canonicalBaseUri);
}

return uriInfo
.getBaseUriBuilder()
.scheme(this.fallbackScheme);
}

/**
* The app's default appspot hosts (project.appspot.com or
* project.REGION.r.appspot.com) route to the traffic-promoted version,
* so links on them can safely be canonicalized. Versioned hosts always
* contain a {@code -dot-} separator (VERSION-dot-SERVICE-dot-project…)
* and must never match.
*/
static boolean isDefaultAppspotHost(@Nullable String host) {
if (host == null) {
return false;
}

var normalized = host.toLowerCase(Locale.ROOT);
return normalized.endsWith(".appspot.com") && !normalized.contains("-dot-");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
package com.google.solutions.jitaccess.web.proposal;

import com.google.api.client.json.GenericJson;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.auth.oauth2.TokenVerifier;
import com.google.common.base.Preconditions;
Expand All @@ -37,6 +38,7 @@

import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
Expand Down Expand Up @@ -231,8 +233,11 @@ boolean reserveProposal(

/**
* Wavemm fork (SECOP-1101): release a reservation taken by
* {@link #reserveProposal} when the propose failed after reserving.
* Best-effort; default no-op.
* {@link #reserveProposal} — either because the propose failed after
* reserving, or because the proposal was approved and the request is
* no longer pending. Best-effort; default no-op. Implementations must
* tolerate keys that were never reserved (approval releases both key
* variants because the variant isn't recoverable from the token).
*/
void releaseProposalReservation(
@NotNull Proposal proposal,
Expand All @@ -251,6 +256,22 @@ void releaseProposalReservation(
payload = this.tokenSigner.verify(proposalToken);
}
catch (TokenVerifier.VerificationException e) {
//
// Wavemm fork: expiry is the common verification failure
// (a reviewer clicking an approval link hours after it was
// minted) and deserves an actionable message. isProbablyExpired
// parses the payload without verifying the signature, so it may
// only ever pick the error message — both paths deny access.
//
if (isProbablyExpired(proposalToken)) {
throw new AccessDeniedException(
String.format(
"This approval link has expired: links can only be approved "
+ "within %s of the request being made. Ask the requester "
+ "to submit a new request.",
formatApproximateDuration(this.options.tokenExpiry)),
e);
}
throw new AccessDeniedException("The proposal token is invalid", e);
}

Expand Down Expand Up @@ -321,11 +342,67 @@ public boolean notifyReviewers() {
public void onCompleted(
@NotNull JitGroupContext.ApprovalOperation op
) throws AccessException, IOException {
//
// The approval executed, so this request is no longer pending:
// release the duplicate-submit reservation (SECOP-1101) so the
// user can request the same group again without waiting for
// this token to expire — with APPROVAL_TIMEOUT well above the
// elevation duration, same-day re-requests are routine. The
// token doesn't record which key variant propose() reserved
// (auto-selected vs hand-picked reviewers), so release both;
// releases are idempotent and best-effort.
//
releaseProposalReservation(this, true);
releaseProposalReservation(this, false);
onProposalApproved(op, this);
}
};
}

/**
* Best-effort check whether a token that failed verification is a
* well-formed JWT whose expiry has passed. The payload is parsed
* WITHOUT verifying the signature, so the result may only be used to
* choose an error message, never to authorize.
*/
private static boolean isProbablyExpired(@NotNull String jwt) {
try {
var parts = jwt.split("\\.");
if (parts.length != 3) {
return false;
}

var payloadJson = new String(
Base64.getUrlDecoder().decode(parts[1]),
StandardCharsets.UTF_8);
try (var parser = new GsonFactory().createJsonParser(payloadJson)) {
var expiry = parser
.parse(JsonWebToken.Payload.class)
.getExpirationTimeSeconds();
return expiry != null && Instant.now().getEpochSecond() > expiry;
}
}
catch (Exception e) {
return false;
}
}

private static @NotNull String formatApproximateDuration(
@NotNull Duration duration
) {
var minutes = duration.toMinutes();
if (minutes == 0) {
return duration.toSeconds() + " seconds";
}
else if (minutes % 60 == 0) {
var hours = minutes / 60;
return hours == 1 ? "1 hour" : hours + " hours";
}
else {
return minutes == 1 ? "1 minute" : minutes + " minutes";
}
}

static class Claims {
static final String RECIPIENT = "rcp";
static final String GROUP_ID = "grp";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.google.solutions.jitaccess.apis.CustomerId;
import org.junit.jupiter.api.Test;

import java.net.URI;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -180,4 +181,47 @@ public void smtp_whenProvided() {
assertEquals("2", configuration.smtpExtraOptionsMap().get("b"));

}

// -------------------------------------------------------------------------
// publicBaseUrl (wavemm fork).
// -------------------------------------------------------------------------

@Test
public void publicBaseUrl_whenNotSet() {
var configuration = new ApplicationConfiguration(createMandatorySettings());

assertFalse(configuration.publicBaseUrl.isPresent());
}

@Test
public void publicBaseUrl_whenSet() {
var settings = new HashMap<>(createMandatorySettings());
settings.put("PUBLIC_BASE_URL", " https://pam.example.com ");

var configuration = new ApplicationConfiguration(settings);

assertEquals(
URI.create("https://pam.example.com"),
configuration.publicBaseUrl.get());
}

@Test
public void publicBaseUrl_whenSchemeNotHttp_thenThrows() {
var settings = new HashMap<>(createMandatorySettings());
settings.put("PUBLIC_BASE_URL", "ftp://pam.example.com");

assertThrows(
IllegalStateException.class,
() -> new ApplicationConfiguration(settings));
}

@Test
public void publicBaseUrl_whenNotAbsolute_thenThrows() {
var settings = new HashMap<>(createMandatorySettings());
settings.put("PUBLIC_BASE_URL", "pam.example.com");

assertThrows(
IllegalStateException.class,
() -> new ApplicationConfiguration(settings));
}
}
Loading
Loading