From f51b09b5e897ef70565ca97134f73271d5868c79 Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:17:18 -0700
Subject: [PATCH 1/7] Disable password saving for credentialless protocols
---
.../java/ch/cyberduck/core/LoginOptions.java | 2 ++
.../ch/cyberduck/core/LoginOptionsTest.java | 19 +++++++++++++++++++
.../cocoa/controller/BookmarkController.java | 1 +
.../controller/ConnectionController.java | 6 ++++--
.../ui/controller/BookmarkController.cs | 1 +
.../ui/controller/ConnectionController.cs | 9 +++++++--
6 files changed, 34 insertions(+), 4 deletions(-)
diff --git a/core/src/main/java/ch/cyberduck/core/LoginOptions.java b/core/src/main/java/ch/cyberduck/core/LoginOptions.java
index be0a3206ae9..4e46928352c 100644
--- a/core/src/main/java/ch/cyberduck/core/LoginOptions.java
+++ b/core/src/main/java/ch/cyberduck/core/LoginOptions.java
@@ -100,6 +100,8 @@ public LoginOptions configure(final Protocol protocol) {
icon = protocol.disk();
usernamePlaceholder = protocol.getUsernamePlaceholder();
passwordPlaceholder = protocol.getPasswordPlaceholder();
+ keychain = password || token || oauth || publickey || certificate;
+ save = keychain && PreferencesFactory.get().getBoolean("connection.login.keychain");
return this;
}
diff --git a/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java b/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java
index c5102bad9cd..bae2cd80807 100644
--- a/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java
+++ b/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java
@@ -17,10 +17,14 @@
* Bug fixes, suggestions and comments should be sent to feedback@cyberduck.ch
*/
+import ch.cyberduck.core.preferences.PreferencesFactory;
+
import org.junit.Test;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
public class LoginOptionsTest {
@@ -33,4 +37,19 @@ public void testEquals() {
b.keychain = true;
assertNotEquals(a, b);
}
+
+ @Test
+ public void testConfigureKeychain() {
+ final LoginOptions options = new LoginOptions(new TestProtocol() {
+ @Override
+ public boolean isPasswordConfigurable() {
+ return false;
+ }
+ });
+ assertFalse(options.keychain());
+ assertFalse(options.save());
+ options.configure(new TestProtocol());
+ assertTrue(options.keychain());
+ assertEquals(PreferencesFactory.get().getBoolean("connection.login.keychain"), options.save());
+ }
}
diff --git a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
index 1dad88ee4a8..b6ea016a3f7 100644
--- a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
+++ b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
@@ -294,6 +294,7 @@ public void protocolSelectionChanged(final NSPopUpButton sender) {
bookmark.setPort(HostnameConfiguratorFactory.get(selected).getPort(bookmark.getHostname()));
bookmark.setCredentials(CredentialsConfiguratorFactory.get(selected).configure(bookmark));
options.configure(selected);
+ bookmark.getCredentials().setSaved(options.save);
validator.configure(selected);
}
this.update();
diff --git a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
index c9fb1559021..8ef2ac9f337 100644
--- a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
+++ b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
@@ -60,8 +60,10 @@ public void setKeychainCheckbox(NSButton keychainCheckbox) {
this.keychainCheckbox = keychainCheckbox;
this.keychainCheckbox.setTarget(this.id());
this.keychainCheckbox.setAction(Foundation.selector("keychainCheckboxClicked:"));
- this.keychainCheckbox.setEnabled(options.keychain);
- this.keychainCheckbox.setState(bookmark.getCredentials().isSaved() ? NSCell.NSOnState : NSCell.NSOffState);
+ this.addObserver(bookmark -> {
+ this.keychainCheckbox.setEnabled(options.keychain);
+ this.keychainCheckbox.setState(bookmark.getCredentials().isSaved() ? NSCell.NSOnState : NSCell.NSOffState);
+ });
}
@Action
diff --git a/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs b/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
index 119c499f1ed..547d94c3689 100644
--- a/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
+++ b/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
@@ -617,6 +617,7 @@ private void View_ChangedProtocolEvent()
_host.setPort(HostnameConfiguratorFactory.get(selected).getPort(_host.getHostname()));
_host.setCredentials(CredentialsConfiguratorFactory.get(selected).configure(_host));
_options.configure(selected);
+ _host.getCredentials().setSaved(_options.save());
_validator.configure(selected);
ItemChanged();
Reachable();
diff --git a/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs b/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
index 2b70e378116..95de0f3ea9f 100644
--- a/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
+++ b/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
@@ -46,8 +46,6 @@ private ConnectionController(Host bookmark, LoginOptions options) : this(bookmar
private ConnectionController(Host bookmark,
LoginInputValidator validator, LoginOptions options) : base(bookmark, validator, options)
{
- View.SavePasswordEnabled = _options.keychain();
- View.SavePasswordChecked = bookmark.getCredentials().isSaved();
View.ChangedSavePasswordCheckboxEvent += View_ChangedSavePasswordCheckboxEvent;
View.ChangedPasswordEvent += delegate { _host.getCredentials().setPassword(View.Password); };
}
@@ -75,6 +73,13 @@ private void View_ChangedSavePasswordCheckboxEvent()
_host.getCredentials().setSaved(View.SavePasswordChecked);
}
+ protected override void Update()
+ {
+ base.Update();
+ View.SavePasswordEnabled = _options.keychain();
+ View.SavePasswordChecked = _host.getCredentials().isSaved();
+ }
+
protected override void ItemChanged()
{
//
From bfc08568eee97971b83231ff1684af77d1920fb9 Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:17:49 -0700
Subject: [PATCH 2/7] Add AWS Console browser sign-in for S3
---
... S3 (AWS Console Sign-In).cyberduckprofile | 83 ++++++
.../ch/cyberduck/core/s3/S3LoginProtocol.java | 51 ++++
.../java/ch/cyberduck/core/s3/S3Session.java | 14 +-
.../AWSConsoleLoginCredentialsStrategy.java | 241 ++++++++++++++++++
...WSConsoleLoginCredentialsStrategyTest.java | 154 +++++++++++
5 files changed, 540 insertions(+), 3 deletions(-)
create mode 100644 profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
create mode 100644 s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java
create mode 100644 s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
create mode 100644 s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java
diff --git a/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
new file mode 100644
index 00000000000..af45ca93b25
--- /dev/null
+++ b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+ Protocol
+ s3-login
+ Vendor
+ iterate GmbH
+ Bundled
+
+ Description
+ Amazon S3 (AWS Console Sign-In)
+ Default Nickname
+ Amazon S3 (AWS Console Sign-In)
+ Hostname Configurable
+
+ Port Configurable
+
+ Password Configurable
+
+ Username Configurable
+
+ Region
+ us-east-1
+ Regions
+
+ af-south-1
+ ap-east-1
+ ap-east-2
+ ap-south-1
+ ap-south-2
+ ap-northeast-1
+ ap-northeast-2
+ ap-northeast-3
+ ap-southeast-1
+ ap-southeast-2
+ ap-southeast-3
+ ap-southeast-4
+ ap-southeast-5
+ ap-southeast-7
+ ca-central-1
+ ca-west-1
+ eu-west-1
+ eu-west-2
+ eu-west-3
+ eu-north-1
+ eu-south-1
+ eu-south-2
+ eu-central-1
+ eu-central-2
+ il-central-1
+ me-central-1
+ me-south-1
+ mx-central-1
+ sa-east-1
+ us-east-1
+ us-east-2
+ us-west-1
+ us-west-2
+
+ Properties
+
+ s3.storage.class.options=STANDARD INTELLIGENT_TIERING STANDARD_IA ONEZONE_IA REDUCED_REDUNDANCY
+ GLACIER GLACIER_IR DEEP_ARCHIVE
+
+
+
+
diff --git a/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java b/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java
new file mode 100644
index 00000000000..081b682103d
--- /dev/null
+++ b/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java
@@ -0,0 +1,51 @@
+package ch.cyberduck.core.s3;
+
+/*
+ * Copyright (c) 2002-2026 iterate GmbH. All rights reserved.
+ * https://cyberduck.io/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+import ch.cyberduck.core.CredentialsConfigurator;
+import ch.cyberduck.core.Protocol;
+
+import com.google.auto.service.AutoService;
+
+@AutoService(Protocol.class)
+public class S3LoginProtocol extends S3Protocol {
+
+ public static final String IDENTIFIER = "s3-login";
+
+ @Override
+ public String getIdentifier() {
+ return IDENTIFIER;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.s3;
+ }
+
+ @Override
+ public String disk() {
+ return String.format("%s.tiff", "s3");
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getFeature(final Class type) {
+ if(type == CredentialsConfigurator.class) {
+ return (T) CredentialsConfigurator.DISABLED;
+ }
+ return super.getFeature(type);
+ }
+}
diff --git a/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java b/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
index d54d883823e..fd896f722e6 100644
--- a/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
+++ b/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
@@ -57,6 +57,7 @@
import ch.cyberduck.core.sso.IdentityCenterAuthorizationService;
import ch.cyberduck.core.sso.IdentityCenterCredentialsStrategy;
import ch.cyberduck.core.sso.RegisterClientOAuth2RequestInterceptor;
+import ch.cyberduck.core.signin.AWSConsoleLoginCredentialsStrategy;
import ch.cyberduck.core.sts.STSAssumeRoleCredentialsStrategy;
import ch.cyberduck.core.sts.STSAssumeRoleWithWebIdentityCredentialsStrategy;
import ch.cyberduck.core.sts.STSAuthorizationService;
@@ -196,7 +197,7 @@ protected String getRestMetadataPrefix() {
@Override
protected RequestEntityRestStorageService connect(final ProxyFinder proxy, final HostKeyCallback hostkey, final LoginCallback prompt, final CancelCallback cancel) throws BackgroundException {
final HttpClientBuilder configuration = builder.build(proxy, this, prompt);
- authentication = this.configureCredentialsStrategy(configuration, prompt);
+ authentication = this.configureCredentialsStrategy(configuration, prompt, cancel);
log.debug("Configured authentication strategy {}", authentication);
configuration.setServiceUnavailableRetryStrategy(new CustomServiceUnavailableRetryStrategy(host,
new S3AuthenticationResponseInterceptor(authentication)));
@@ -265,7 +266,12 @@ public void process(final HttpRequest request, final HttpContext context) {
}
protected S3CredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration,
- final LoginCallback prompt) throws BackgroundException {
+ final LoginCallback prompt,
+ final CancelCallback cancel) throws BackgroundException {
+ if(S3LoginProtocol.IDENTIFIER.equals(host.getProtocol().getIdentifier())) {
+ log.debug("Configure AWS Console Sign-In");
+ return new AWSConsoleLoginCredentialsStrategy(host, cancel);
+ }
if(host.getProtocol().isOAuthConfigurable()) {
if(host.getProtocol().getOAuthScopes().contains(IdentityCenterCredentialsStrategy.SSO_ACCOUNT_ACCESS_SCOPE)) {
log.debug("Configure SSO");
@@ -360,7 +366,9 @@ public void login(final LoginCallback prompt, final CancelCallback cancel) throw
if(!credentials.isAnonymousLogin()) {
// Returns details about the IAM user or role whose credentials are used to call the operation.
// No permissions are required to perform this operation.
- new STSAuthorizationService(host, trust, key, prompt).getCallerIdentity(credentials);
+ if(!S3LoginProtocol.IDENTIFIER.equals(host.getProtocol().getIdentifier())) {
+ new STSAuthorizationService(host, trust, key, prompt).getCallerIdentity(credentials);
+ }
return;
}
}
diff --git a/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
new file mode 100644
index 00000000000..fc313478088
--- /dev/null
+++ b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
@@ -0,0 +1,241 @@
+package ch.cyberduck.core.signin;
+
+/*
+ * Copyright (c) 2002-2026 iterate GmbH. All rights reserved.
+ * https://cyberduck.io/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+import ch.cyberduck.core.Credentials;
+import ch.cyberduck.core.Factory;
+import ch.cyberduck.core.Host;
+import ch.cyberduck.core.TemporaryAccessTokens;
+import ch.cyberduck.core.exception.BackgroundException;
+import ch.cyberduck.core.exception.LoginCanceledException;
+import ch.cyberduck.core.exception.LoginFailureException;
+import ch.cyberduck.core.io.StreamGobbler;
+import ch.cyberduck.core.s3.S3CredentialsStrategy;
+import ch.cyberduck.core.threading.CancelCallback;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Temporary S3 credentials exported by an AWS CLI browser-login profile.
+ */
+public class AWSConsoleLoginCredentialsStrategy implements S3CredentialsStrategy {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ static final String DEFAULT_REGION = "us-east-1";
+ static final String PROFILE_PREFIX = "cyberduck";
+ static final String IDENTITY_PROPERTY = "s3.login.identity";
+
+ private final Host host;
+ private final CancelCallback cancel;
+ private final String profile;
+ private TemporaryAccessTokens tokens = TemporaryAccessTokens.EMPTY;
+
+ public AWSConsoleLoginCredentialsStrategy(final Host host, final CancelCallback cancel) {
+ this.host = host;
+ this.cancel = cancel;
+ this.profile = String.format("%s-%s", PROFILE_PREFIX, host.getUuid());
+ host.setCredentials(new Credentials().setSaved(false));
+ }
+
+ @Override
+ public synchronized Credentials get() throws BackgroundException {
+ if(tokens.isExpired()) {
+ Result result = this.export();
+ if(!result.success()) {
+ final Result login = this.execute("login", "--profile", profile, "--region",
+ StringUtils.defaultIfBlank(host.getRegion(), DEFAULT_REGION),
+ "--no-cli-auto-prompt", "--no-cli-pager");
+ if(!login.success()) {
+ throw failure(login.error);
+ }
+ result = this.export();
+ }
+ if(!result.success()) {
+ throw failure(result.error);
+ }
+ final TemporaryAccessTokens refreshed = parse(result.output);
+ this.validateIdentity();
+ tokens = refreshed;
+ }
+ return new Credentials().setTokens(tokens).setSaved(false);
+ }
+
+ private Result export() throws BackgroundException {
+ return this.execute("configure", "export-credentials", "--profile", profile, "--region",
+ StringUtils.defaultIfBlank(host.getRegion(), DEFAULT_REGION), "--format", "process",
+ "--no-cli-auto-prompt", "--no-cli-pager");
+ }
+
+ private void validateIdentity() throws BackgroundException {
+ final Result result = this.execute("sts", "get-caller-identity", "--profile", profile, "--region",
+ StringUtils.defaultIfBlank(host.getRegion(), DEFAULT_REGION), "--output", "json",
+ "--no-cli-auto-prompt", "--no-cli-pager");
+ if(!result.success()) {
+ throw failure(result.error);
+ }
+ try {
+ final JsonNode identity = MAPPER.readTree(result.output);
+ if(null == identity || !identity.isObject()) {
+ throw failure();
+ }
+ validateIdentity(host, identity.path("Account").asText(), identity.path("Arn").asText());
+ }
+ catch(IOException e) {
+ throw failure(e);
+ }
+ }
+
+ protected Result execute(final String... arguments) throws BackgroundException {
+ final List command = new ArrayList<>();
+ command.add(executable());
+ Collections.addAll(command, arguments);
+ final Process process;
+ try {
+ process = new ProcessBuilder(command).start();
+ }
+ catch(IOException e) {
+ throw failure(e);
+ }
+ try(InputStream output = new StreamGobbler(process.getInputStream());
+ InputStream error = new StreamGobbler(process.getErrorStream())) {
+ // Browser login must never wait on an invisible terminal prompt.
+ process.getOutputStream().close();
+ while(!process.waitFor(250L, TimeUnit.MILLISECONDS)) {
+ cancel.verify();
+ }
+ return new Result(process.exitValue(),
+ IOUtils.toString(output, StandardCharsets.UTF_8),
+ IOUtils.toString(error, StandardCharsets.UTF_8));
+ }
+ catch(InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new LoginCanceledException(e);
+ }
+ catch(IOException e) {
+ throw failure(e);
+ }
+ finally {
+ process.destroy();
+ }
+ }
+
+ private static TemporaryAccessTokens parse(final String output) throws LoginFailureException {
+ try {
+ final JsonNode value = MAPPER.readTree(output);
+ if(null == value || !value.isObject()) {
+ throw failure();
+ }
+ final String accessKey = value.path("AccessKeyId").asText();
+ final String secretKey = value.path("SecretAccessKey").asText();
+ final String sessionToken = value.path("SessionToken").asText();
+ final long expiration = OffsetDateTime.parse(value.path("Expiration").asText()).toInstant().toEpochMilli();
+ if(StringUtils.isAnyBlank(accessKey, secretKey, sessionToken) || expiration <= System.currentTimeMillis()) {
+ throw failure();
+ }
+ return new TemporaryAccessTokens(accessKey, secretKey, sessionToken, expiration);
+ }
+ catch(IOException | DateTimeParseException e) {
+ throw failure(e);
+ }
+ }
+
+ private static String executable() {
+ if(Factory.Platform.Name.mac.equals(Factory.Platform.getDefault())) {
+ for(String candidate : new String[]{"/usr/local/bin/aws", "/opt/homebrew/bin/aws"}) {
+ if(Files.isExecutable(Paths.get(candidate))) {
+ return candidate;
+ }
+ }
+ }
+ return Factory.Platform.Name.windows.equals(Factory.Platform.getDefault()) ? "aws.exe" : "aws";
+ }
+
+ private static String identity(final String account, final String arn) {
+ String resource = StringUtils.substringAfterLast(arn, ":");
+ if(StringUtils.startsWith(resource, "assumed-role/")) {
+ resource = String.format("role/%s", StringUtils.substringBeforeLast(
+ StringUtils.removeStart(resource, "assumed-role/"), "/"));
+ }
+ return String.format("%s/%s", account, resource);
+ }
+
+ static void validateIdentity(final Host host, final String account, final String arn) throws LoginFailureException {
+ if(StringUtils.isAnyBlank(account, arn)) {
+ throw failure();
+ }
+ final String selected = identity(account, arn);
+ final String pinned = host.getProperty(IDENTITY_PROPERTY);
+ if(StringUtils.isNotBlank(pinned) && !StringUtils.equals(pinned, selected)) {
+ throw new LoginFailureException(String.format(
+ "AWS identity %s does not match this bookmark (%s). "
+ + "Create a new bookmark to use the other identity.", selected, pinned));
+ }
+ host.setProperty(IDENTITY_PROPERTY, selected);
+ if(StringUtils.isBlank(host.getNickname())) {
+ host.setNickname(selected);
+ }
+ }
+
+ private static LoginFailureException failure() {
+ return failure((Throwable) null);
+ }
+
+ private static LoginFailureException failure(final Throwable cause) {
+ return new LoginFailureException(
+ "AWS browser sign-in failed. AWS CLI 2.32 or later is required.",
+ cause);
+ }
+
+ private static LoginFailureException failure(final String detail) {
+ if(StringUtils.isBlank(detail)) {
+ return failure();
+ }
+ return new LoginFailureException(String.format(
+ "AWS browser sign-in failed: %s", StringUtils.abbreviate(StringUtils.trim(detail), 1000)));
+ }
+
+ protected static final class Result {
+ private final int status;
+ private final String output;
+ private final String error;
+
+ protected Result(final int status, final String output, final String error) {
+ this.status = status;
+ this.output = output;
+ this.error = error;
+ }
+
+ private boolean success() {
+ return status == 0;
+ }
+ }
+}
diff --git a/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java b/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java
new file mode 100644
index 00000000000..66b0253cbe5
--- /dev/null
+++ b/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java
@@ -0,0 +1,154 @@
+package ch.cyberduck.core.signin;
+
+/*
+ * Copyright (c) 2002-2026 iterate GmbH. All rights reserved.
+ * https://cyberduck.io/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+import ch.cyberduck.core.Credentials;
+import ch.cyberduck.core.Host;
+import ch.cyberduck.core.exception.LoginFailureException;
+import ch.cyberduck.core.s3.S3LoginProtocol;
+import ch.cyberduck.core.threading.CancelCallback;
+
+import org.junit.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Queue;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class AWSConsoleLoginCredentialsStrategyTest {
+
+ @Test
+ public void testExportAndMemoryCache() throws Exception {
+ final Host host = new Host(new S3LoginProtocol()).setUuid("bookmark");
+ host.getCredentials().setSaved(true);
+ final TestStrategy strategy = new TestStrategy(host,
+ new AWSConsoleLoginCredentialsStrategy.Result(0, credentials(), ""),
+ new AWSConsoleLoginCredentialsStrategy.Result(0,
+ identity("arn:aws:iam::123456789012:user/alice"), ""));
+
+ final Credentials first = strategy.get();
+ final Credentials second = strategy.get();
+
+ assertEquals("ASIAEXAMPLE", first.getTokens().getAccessKeyId());
+ assertEquals("secret", first.getTokens().getSecretAccessKey());
+ assertEquals("session", first.getTokens().getSessionToken());
+ assertFalse(first.isSaved());
+ assertFalse(second.isSaved());
+ assertFalse(host.getCredentials().isSaved());
+ assertEquals(2, strategy.commands.size());
+ assertEquals(Arrays.asList("configure", "export-credentials", "--profile", "cyberduck-bookmark",
+ "--region", "us-east-1", "--format", "process", "--no-cli-auto-prompt", "--no-cli-pager"),
+ strategy.commands.get(0));
+ assertEquals(Arrays.asList("sts", "get-caller-identity", "--profile", "cyberduck-bookmark",
+ "--region", "us-east-1", "--output", "json", "--no-cli-auto-prompt", "--no-cli-pager"),
+ strategy.commands.get(1));
+ }
+
+ @Test
+ public void testLoginWhenExportFails() throws Exception {
+ final TestStrategy strategy = new TestStrategy(
+ new Host(new S3LoginProtocol()).setRegion("eu-west-1").setUuid("bookmark"),
+ new AWSConsoleLoginCredentialsStrategy.Result(1, "No credentials", ""),
+ new AWSConsoleLoginCredentialsStrategy.Result(0, "Login succeeded", ""),
+ new AWSConsoleLoginCredentialsStrategy.Result(0, credentials(), ""),
+ new AWSConsoleLoginCredentialsStrategy.Result(0,
+ identity("arn:aws:iam::123456789012:user/alice"), ""));
+
+ strategy.get();
+
+ assertEquals(4, strategy.commands.size());
+ assertEquals(Arrays.asList("configure", "export-credentials", "--profile", "cyberduck-bookmark",
+ "--region", "eu-west-1", "--format", "process", "--no-cli-auto-prompt", "--no-cli-pager"),
+ strategy.commands.get(0));
+ assertEquals(Arrays.asList("login", "--profile", "cyberduck-bookmark", "--region", "eu-west-1",
+ "--no-cli-auto-prompt", "--no-cli-pager"), strategy.commands.get(1));
+ }
+
+ @Test
+ public void testShowLoginErrorWithoutCredentialOutput() {
+ final TestStrategy strategy = new TestStrategy(new Host(new S3LoginProtocol()),
+ new AWSConsoleLoginCredentialsStrategy.Result(1, "credentials", ""),
+ new AWSConsoleLoginCredentialsStrategy.Result(1, "sign-in URL", "permission denied"));
+
+ final LoginFailureException failure = assertThrows(LoginFailureException.class, strategy::get);
+ assertTrue(failure.getDetail(false).contains("permission denied"));
+ assertFalse(failure.getDetail(false).contains("credentials"));
+ }
+
+ @Test
+ public void testRejectPermanentCredentials() {
+ final TestStrategy strategy = new TestStrategy(new Host(new S3LoginProtocol()),
+ new AWSConsoleLoginCredentialsStrategy.Result(0,
+ "{\"Version\":1,\"AccessKeyId\":\"key\",\"SecretAccessKey\":\"secret\"}", ""));
+
+ assertThrows(LoginFailureException.class, strategy::get);
+ }
+
+ @Test
+ public void testPinIdentity() throws Exception {
+ final Host host = new Host(new S3LoginProtocol());
+ AWSConsoleLoginCredentialsStrategy.validateIdentity(host, "123456789012",
+ "arn:aws:iam::123456789012:user/alice");
+ assertEquals("123456789012/user/alice",
+ host.getProperty(AWSConsoleLoginCredentialsStrategy.IDENTITY_PROPERTY));
+ assertEquals("123456789012/user/alice", host.getNickname());
+
+ AWSConsoleLoginCredentialsStrategy.validateIdentity(host, "123456789012",
+ "arn:aws:iam::123456789012:user/alice");
+ assertThrows(LoginFailureException.class, () -> AWSConsoleLoginCredentialsStrategy.validateIdentity(host,
+ "123456789012", "arn:aws:sts::123456789012:assumed-role/Admin/session"));
+ }
+
+ @Test
+ public void testRoleIdentityDropsSessionName() throws Exception {
+ final Host host = new Host(new S3LoginProtocol());
+ AWSConsoleLoginCredentialsStrategy.validateIdentity(host, "123456789012",
+ "arn:aws:sts::123456789012:assumed-role/Admin/session");
+ assertEquals("123456789012/role/Admin",
+ host.getProperty(AWSConsoleLoginCredentialsStrategy.IDENTITY_PROPERTY));
+ }
+
+ private static String credentials() {
+ return "{\"Version\":1,\"AccessKeyId\":\"ASIAEXAMPLE\",\"SecretAccessKey\":\"secret\","
+ + "\"SessionToken\":\"session\",\"Expiration\":\"2099-01-01T00:00:00+00:00\"}";
+ }
+
+ private static String identity(final String arn) {
+ return String.format("{\"Account\":\"123456789012\",\"Arn\":\"%s\"}", arn);
+ }
+
+ private static final class TestStrategy extends AWSConsoleLoginCredentialsStrategy {
+ private final Queue results = new ArrayDeque<>();
+ private final List> commands = new ArrayList<>();
+
+ private TestStrategy(final Host host, final Result... results) {
+ super(host, CancelCallback.noop);
+ this.results.addAll(Arrays.asList(results));
+ }
+
+ @Override
+ protected Result execute(final String... arguments) {
+ commands.add(Arrays.asList(arguments));
+ return results.remove();
+ }
+ }
+}
From eb4aea0532df6d4ba6d84e3ab5feacd8c4d060a1 Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:18:02 -0700
Subject: [PATCH 3/7] Use fork process launch for packaged macOS app
---
osx/build.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/osx/build.xml b/osx/build.xml
index 20708c262ab..c706077398e 100644
--- a/osx/build.xml
+++ b/osx/build.xml
@@ -31,7 +31,7 @@
+ value="-client --add-opens=java.base/sun.security.ssl=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Djdk.lang.Process.launchMechanism=FORK -Djava.library.path=$APP_PACKAGE/Contents/Frameworks -Djna.boot.library.path=$APP_PACKAGE/Contents/Frameworks -Djna.library.path=$APP_PACKAGE/Contents/Frameworks -Djna.nounpack=true -Djava.awt.headless=true -Dsun.jnu.encoding=utf-8 -Dfile.encoding=utf-8 -Dsun.io.useCanonCaches=false -DLog4jContextSelector=org.apache.logging.log4j.core.selector.BasicContextSelector -XX:+UseShenandoahGC -XX:ShenandoahGCHeuristics=compact -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 -XX:+UseStringDeduplication"/>
From be032b1646ff283481fff4819497c9a26e4248fb Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:56:44 -0700
Subject: [PATCH 4/7] Fix protocol switching and Spectra override
---
.../ch/cyberduck/ui/cocoa/controller/BookmarkController.java | 2 +-
.../main/java/ch/cyberduck/core/spectra/SpectraSession.java | 4 +++-
.../csharp/ch/cyberduck/ui/controller/BookmarkController.cs | 2 +-
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
index b6ea016a3f7..ae528a9e71a 100644
--- a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
+++ b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
@@ -294,7 +294,7 @@ public void protocolSelectionChanged(final NSPopUpButton sender) {
bookmark.setPort(HostnameConfiguratorFactory.get(selected).getPort(bookmark.getHostname()));
bookmark.setCredentials(CredentialsConfiguratorFactory.get(selected).configure(bookmark));
options.configure(selected);
- bookmark.getCredentials().setSaved(options.save);
+ bookmark.getCredentials().setSaved(options.keychain && bookmark.getCredentials().isSaved());
validator.configure(selected);
}
this.update();
diff --git a/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java b/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java
index 845bbb0845a..0965f7c8721 100644
--- a/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java
+++ b/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java
@@ -57,7 +57,9 @@ public SpectraCredentialsStrategy getAuthentication() {
}
@Override
- protected SpectraCredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration, final LoginCallback prompt) {
+ protected SpectraCredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration,
+ final LoginCallback prompt,
+ final CancelCallback cancel) {
return new SpectraCredentialsStrategy(host.getCredentials());
}
diff --git a/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs b/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
index 547d94c3689..cc290134c17 100644
--- a/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
+++ b/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
@@ -617,7 +617,7 @@ private void View_ChangedProtocolEvent()
_host.setPort(HostnameConfiguratorFactory.get(selected).getPort(_host.getHostname()));
_host.setCredentials(CredentialsConfiguratorFactory.get(selected).configure(_host));
_options.configure(selected);
- _host.getCredentials().setSaved(_options.save());
+ _host.getCredentials().setSaved(_options.keychain() && _host.getCredentials().isSaved());
_validator.configure(selected);
ItemChanged();
Reachable();
From b064c4af8726ca2046c047e3aa9c8ae40dfe5f48 Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Sun, 2 Aug 2026 01:58:34 -0700
Subject: [PATCH 5/7] Address AWS sign-in review feedback
---
.../java/ch/cyberduck/core/LoginOptions.java | 5 ++---
.../ch/cyberduck/core/LoginOptionsTest.java | 19 -------------------
osx/build.xml | 2 +-
.../cocoa/controller/BookmarkController.java | 1 -
.../controller/ConnectionController.java | 2 +-
... S3 (AWS Console Sign-In).cyberduckprofile | 2 ++
.../java/ch/cyberduck/core/s3/S3Session.java | 4 ++--
.../ui/controller/BookmarkController.cs | 1 -
.../ui/controller/ConnectionController.cs | 2 +-
9 files changed, 9 insertions(+), 29 deletions(-)
diff --git a/core/src/main/java/ch/cyberduck/core/LoginOptions.java b/core/src/main/java/ch/cyberduck/core/LoginOptions.java
index 4e46928352c..f62c0cbe4c4 100644
--- a/core/src/main/java/ch/cyberduck/core/LoginOptions.java
+++ b/core/src/main/java/ch/cyberduck/core/LoginOptions.java
@@ -100,9 +100,8 @@ public LoginOptions configure(final Protocol protocol) {
icon = protocol.disk();
usernamePlaceholder = protocol.getUsernamePlaceholder();
passwordPlaceholder = protocol.getPasswordPlaceholder();
- keychain = password || token || oauth || publickey || certificate;
- save = keychain && PreferencesFactory.get().getBoolean("connection.login.keychain");
- return this;
+ return this.keychain(Boolean.parseBoolean(protocol.getProperties().getOrDefault(
+ "connection.login.keychain", Boolean.TRUE.toString())));
}
public LoginOptions user(boolean e) {
diff --git a/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java b/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java
index bae2cd80807..c5102bad9cd 100644
--- a/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java
+++ b/core/src/test/java/ch/cyberduck/core/LoginOptionsTest.java
@@ -17,14 +17,10 @@
* Bug fixes, suggestions and comments should be sent to feedback@cyberduck.ch
*/
-import ch.cyberduck.core.preferences.PreferencesFactory;
-
import org.junit.Test;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertTrue;
public class LoginOptionsTest {
@@ -37,19 +33,4 @@ public void testEquals() {
b.keychain = true;
assertNotEquals(a, b);
}
-
- @Test
- public void testConfigureKeychain() {
- final LoginOptions options = new LoginOptions(new TestProtocol() {
- @Override
- public boolean isPasswordConfigurable() {
- return false;
- }
- });
- assertFalse(options.keychain());
- assertFalse(options.save());
- options.configure(new TestProtocol());
- assertTrue(options.keychain());
- assertEquals(PreferencesFactory.get().getBoolean("connection.login.keychain"), options.save());
- }
}
diff --git a/osx/build.xml b/osx/build.xml
index c706077398e..20708c262ab 100644
--- a/osx/build.xml
+++ b/osx/build.xml
@@ -31,7 +31,7 @@
+ value="-client --add-opens=java.base/sun.security.ssl=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Djava.library.path=$APP_PACKAGE/Contents/Frameworks -Djna.boot.library.path=$APP_PACKAGE/Contents/Frameworks -Djna.library.path=$APP_PACKAGE/Contents/Frameworks -Djna.nounpack=true -Djava.awt.headless=true -Dsun.jnu.encoding=utf-8 -Dfile.encoding=utf-8 -Dsun.io.useCanonCaches=false -DLog4jContextSelector=org.apache.logging.log4j.core.selector.BasicContextSelector -XX:+UseShenandoahGC -XX:ShenandoahGCHeuristics=compact -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=20 -XX:+UseStringDeduplication"/>
diff --git a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
index ae528a9e71a..1dad88ee4a8 100644
--- a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
+++ b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/BookmarkController.java
@@ -294,7 +294,6 @@ public void protocolSelectionChanged(final NSPopUpButton sender) {
bookmark.setPort(HostnameConfiguratorFactory.get(selected).getPort(bookmark.getHostname()));
bookmark.setCredentials(CredentialsConfiguratorFactory.get(selected).configure(bookmark));
options.configure(selected);
- bookmark.getCredentials().setSaved(options.keychain && bookmark.getCredentials().isSaved());
validator.configure(selected);
}
this.update();
diff --git a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
index 8ef2ac9f337..e57056411e3 100644
--- a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
+++ b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
@@ -62,7 +62,7 @@ public void setKeychainCheckbox(NSButton keychainCheckbox) {
this.keychainCheckbox.setAction(Foundation.selector("keychainCheckboxClicked:"));
this.addObserver(bookmark -> {
this.keychainCheckbox.setEnabled(options.keychain);
- this.keychainCheckbox.setState(bookmark.getCredentials().isSaved() ? NSCell.NSOnState : NSCell.NSOffState);
+ this.keychainCheckbox.setState(options.keychain && bookmark.getCredentials().isSaved() ? NSCell.NSOnState : NSCell.NSOffState);
});
}
diff --git a/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
index af45ca93b25..84da65c3025 100644
--- a/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
+++ b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
@@ -75,6 +75,8 @@
Properties
+ connection.login.keychain=false
+ s3.login.enable=true
s3.storage.class.options=STANDARD INTELLIGENT_TIERING STANDARD_IA ONEZONE_IA REDUCED_REDUNDANCY
GLACIER GLACIER_IR DEEP_ARCHIVE
diff --git a/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java b/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
index fd896f722e6..1aec532d370 100644
--- a/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
+++ b/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
@@ -268,7 +268,7 @@ public void process(final HttpRequest request, final HttpContext context) {
protected S3CredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration,
final LoginCallback prompt,
final CancelCallback cancel) throws BackgroundException {
- if(S3LoginProtocol.IDENTIFIER.equals(host.getProtocol().getIdentifier())) {
+ if(preferences.getBoolean("s3.login.enable")) {
log.debug("Configure AWS Console Sign-In");
return new AWSConsoleLoginCredentialsStrategy(host, cancel);
}
@@ -366,7 +366,7 @@ public void login(final LoginCallback prompt, final CancelCallback cancel) throw
if(!credentials.isAnonymousLogin()) {
// Returns details about the IAM user or role whose credentials are used to call the operation.
// No permissions are required to perform this operation.
- if(!S3LoginProtocol.IDENTIFIER.equals(host.getProtocol().getIdentifier())) {
+ if(!preferences.getBoolean("s3.login.enable")) {
new STSAuthorizationService(host, trust, key, prompt).getCallerIdentity(credentials);
}
return;
diff --git a/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs b/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
index cc290134c17..119c499f1ed 100644
--- a/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
+++ b/windows/src/main/csharp/ch/cyberduck/ui/controller/BookmarkController.cs
@@ -617,7 +617,6 @@ private void View_ChangedProtocolEvent()
_host.setPort(HostnameConfiguratorFactory.get(selected).getPort(_host.getHostname()));
_host.setCredentials(CredentialsConfiguratorFactory.get(selected).configure(_host));
_options.configure(selected);
- _host.getCredentials().setSaved(_options.keychain() && _host.getCredentials().isSaved());
_validator.configure(selected);
ItemChanged();
Reachable();
diff --git a/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs b/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
index 95de0f3ea9f..e4b0839bd42 100644
--- a/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
+++ b/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
@@ -77,7 +77,7 @@ protected override void Update()
{
base.Update();
View.SavePasswordEnabled = _options.keychain();
- View.SavePasswordChecked = _host.getCredentials().isSaved();
+ View.SavePasswordChecked = _options.keychain() && _host.getCredentials().isSaved();
}
protected override void ItemChanged()
From a226a1fb8c50eaf91a8aaa1ea59f9233748c8efd Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Sun, 2 Aug 2026 15:57:38 -0700
Subject: [PATCH 6/7] Implement native AWS Console sign-in
---
.../java/ch/cyberduck/core/LoginOptions.java | 3 +-
...opbackOAuth2AuthorizationCodeProvider.java | 82 +++--
.../oauth/OAuth2TokenListenerRegistry.java | 2 +-
.../controller/ConnectionController.java | 6 +-
... S3 (AWS Console Sign-In).cyberduckprofile | 1 -
.../ch/cyberduck/core/s3/S3LoginProtocol.java | 4 +-
.../java/ch/cyberduck/core/s3/S3Session.java | 11 +-
.../AWSConsoleLoginCredentialsStrategy.java | 337 +++++++++++-------
...WSConsoleLoginCredentialsStrategyTest.java | 93 +----
.../core/spectra/SpectraSession.java | 4 +-
.../ui/controller/ConnectionController.cs | 9 +-
11 files changed, 281 insertions(+), 271 deletions(-)
diff --git a/core/src/main/java/ch/cyberduck/core/LoginOptions.java b/core/src/main/java/ch/cyberduck/core/LoginOptions.java
index f62c0cbe4c4..be0a3206ae9 100644
--- a/core/src/main/java/ch/cyberduck/core/LoginOptions.java
+++ b/core/src/main/java/ch/cyberduck/core/LoginOptions.java
@@ -100,8 +100,7 @@ public LoginOptions configure(final Protocol protocol) {
icon = protocol.disk();
usernamePlaceholder = protocol.getUsernamePlaceholder();
passwordPlaceholder = protocol.getPasswordPlaceholder();
- return this.keychain(Boolean.parseBoolean(protocol.getProperties().getOrDefault(
- "connection.login.keychain", Boolean.TRUE.toString())));
+ return this;
}
public LoginOptions user(boolean e) {
diff --git a/oauth/src/main/java/ch/cyberduck/core/oauth/LoopbackOAuth2AuthorizationCodeProvider.java b/oauth/src/main/java/ch/cyberduck/core/oauth/LoopbackOAuth2AuthorizationCodeProvider.java
index 4f1390ea1b9..d403b5aecc7 100644
--- a/oauth/src/main/java/ch/cyberduck/core/oauth/LoopbackOAuth2AuthorizationCodeProvider.java
+++ b/oauth/src/main/java/ch/cyberduck/core/oauth/LoopbackOAuth2AuthorizationCodeProvider.java
@@ -34,17 +34,17 @@
import org.apache.logging.log4j.Logger;
import java.io.IOException;
+import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
-import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
-import com.sun.net.httpserver.HttpExchange;
-import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
public class LoopbackOAuth2AuthorizationCodeProvider extends BrowserOAuth2AuthorizationCodeProvider {
@@ -52,59 +52,79 @@ public class LoopbackOAuth2AuthorizationCodeProvider extends BrowserOAuth2Author
@Override
public String prompt(final Host bookmark, final LoginCallback prompt, final String authorizationCodeUrl, final String redirectUri, final String state) throws BackgroundException {
+ return this.prompt(bookmark, prompt, ignored -> authorizationCodeUrl, redirectUri, state);
+ }
+
+ public String prompt(final Host bookmark, final LoginCallback prompt,
+ final Function authorizationCodeUrl, final String state) throws BackgroundException {
+ return this.prompt(bookmark, prompt, authorizationCodeUrl, null, state);
+ }
+
+ private String prompt(final Host bookmark, final LoginCallback prompt,
+ final Function authorizationCodeUrl,
+ final String requestedRedirectUri, final String expectedState) throws BackgroundException {
final CountDownLatch signal = new CountDownLatch(1);
- final OAuth2TokenListenerRegistry registry = OAuth2TokenListenerRegistry.get();
final AtomicReference authenticationCode = new AtomicReference<>();
- registry.register(state, new OAuth2TokenListener() {
- @Override
- public void callback(final String code) {
- log.info("Callback with code {}", code);
- if(!StringUtils.isBlank(code)) {
- authenticationCode.set(code);
- }
+ OAuth2TokenListenerRegistry.get().register(expectedState, code -> {
+ if(StringUtils.isBlank(code)) {
signal.countDown();
}
+ else {
+ authenticationCode.set(code);
+ }
});
try {
- final HttpServer server = HttpServer.create(new InetSocketAddress(
- URI.create(redirectUri).getHost(), -1 == URI.create(redirectUri).getPort() ? 0 : URI.create(redirectUri).getPort()), 0);
+ final URI requested = null == requestedRedirectUri ? null : URI.create(requestedRedirectUri);
+ final HttpServer server = HttpServer.create(null == requested ?
+ new InetSocketAddress(InetAddress.getByAddress(new byte[]{127, 0, 0, 1}), 0) :
+ new InetSocketAddress(requested.getHost(), -1 == requested.getPort() ? 0 : requested.getPort()), 0);
+ final String redirectUri = null == requested ? String.format("http://127.0.0.1:%d/oauth/callback", server.getAddress().getPort()) : requestedRedirectUri;
final ExecutorService executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("oauth"));
// Create handler for OAuth callback
server.createContext(StringUtils.isBlank(URI.create(redirectUri).getRawPath()) ?
- String.valueOf(Path.DELIMITER) : URI.create(redirectUri).getRawPath(), new HttpHandler() {
- @Override
- public void handle(final HttpExchange exchange) throws IOException {
- log.debug("Received callback with query {}", exchange.getRequestURI().getQuery());
- final List pairs = URLEncodedUtils.parse(exchange.getRequestURI(), Charset.defaultCharset());
+ String.valueOf(Path.DELIMITER) : URI.create(redirectUri).getRawPath(), exchange -> {
+ final List pairs = URLEncodedUtils.parse(exchange.getRequestURI(), StandardCharsets.UTF_8);
String state = StringUtils.EMPTY;
String code = StringUtils.EMPTY;
for(NameValuePair pair : pairs) {
if(StringUtils.equals(pair.getName(), "state")) {
- state = StringUtils.equals(pair.getName(), "state") ? pair.getValue() : StringUtils.EMPTY;
+ state = pair.getValue();
}
if(StringUtils.equals(pair.getName(), "code")) {
- code = StringUtils.equals(pair.getName(), "code") ? pair.getValue() : StringUtils.EMPTY;
+ code = pair.getValue();
}
}
- final OAuth2TokenListenerRegistry oauth = OAuth2TokenListenerRegistry.get();
- if(oauth.notify(state, code)) {
- exchange.getResponseHeaders().add(HttpHeaders.LOCATION, OAuth2AuthorizationService.CYBERDUCK_REDIRECT_URI);
- exchange.sendResponseHeaders(302, 0L);
+ final boolean accepted = StringUtils.equals(expectedState, state) && OAuth2TokenListenerRegistry.get().notify(state, code);
+ try {
+ if(!accepted) {
+ exchange.sendResponseHeaders(400, 0);
+ }
+ else if(null == requested) {
+ final byte[] response = LocaleFactory.localizedString("Login successful", "Credentials").getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/plain; charset=utf-8");
+ exchange.sendResponseHeaders(200, response.length);
+ exchange.getResponseBody().write(response);
+ }
+ else {
+ exchange.getResponseHeaders().add(HttpHeaders.LOCATION, OAuth2AuthorizationService.CYBERDUCK_REDIRECT_URI);
+ exchange.sendResponseHeaders(302, 0L);
+ }
}
- else {
- exchange.sendResponseHeaders(400, 0);
+ finally {
+ IOUtils.close(exchange.getResponseBody());
+ if(accepted) {
+ signal.countDown();
+ }
}
- IOUtils.close(exchange.getResponseBody());
- }
});
server.setExecutor(executor);
server.start();
log.info("Started OAuth callback server {}", server);
try {
// Open browser with authorization URL
- this.open(authorizationCodeUrl);
+ this.open(authorizationCodeUrl.apply(redirectUri));
// Wait for callback
- log.info("Await callback from custom scheme {} and state {}", redirectUri, state);
+ log.info("Await callback from custom scheme {} and state {}", redirectUri, expectedState);
prompt.await(signal, bookmark, String.format("%s %s", LocaleFactory.localizedString("Login", "Login"), BookmarkNameProvider.toString(bookmark, true)),
LocaleFactory.localizedString("Open web browser to authenticate and obtain an authorization code", "Credentials"));
bookmark.getCredentials().setSaved(new LoginOptions().save);
@@ -119,4 +139,4 @@ public void handle(final HttpExchange exchange) throws IOException {
throw new DefaultIOExceptionMappingService().map(e);
}
}
-}
\ No newline at end of file
+}
diff --git a/oauth/src/main/java/ch/cyberduck/core/oauth/OAuth2TokenListenerRegistry.java b/oauth/src/main/java/ch/cyberduck/core/oauth/OAuth2TokenListenerRegistry.java
index c8864b8b11b..998a5f636b7 100644
--- a/oauth/src/main/java/ch/cyberduck/core/oauth/OAuth2TokenListenerRegistry.java
+++ b/oauth/src/main/java/ch/cyberduck/core/oauth/OAuth2TokenListenerRegistry.java
@@ -47,7 +47,7 @@ public boolean notify(final String state, final String token) {
return false;
}
listeners.remove(state);
- log.debug("Notify listener for state {} with token {}", state, token);
+ log.debug("Notify listener for state {}", state);
listener.callback(token);
return true;
}
diff --git a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
index e57056411e3..c9fb1559021 100644
--- a/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
+++ b/osx/src/main/java/ch/cyberduck/ui/cocoa/controller/ConnectionController.java
@@ -60,10 +60,8 @@ public void setKeychainCheckbox(NSButton keychainCheckbox) {
this.keychainCheckbox = keychainCheckbox;
this.keychainCheckbox.setTarget(this.id());
this.keychainCheckbox.setAction(Foundation.selector("keychainCheckboxClicked:"));
- this.addObserver(bookmark -> {
- this.keychainCheckbox.setEnabled(options.keychain);
- this.keychainCheckbox.setState(options.keychain && bookmark.getCredentials().isSaved() ? NSCell.NSOnState : NSCell.NSOffState);
- });
+ this.keychainCheckbox.setEnabled(options.keychain);
+ this.keychainCheckbox.setState(bookmark.getCredentials().isSaved() ? NSCell.NSOnState : NSCell.NSOffState);
}
@Action
diff --git a/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
index 84da65c3025..6fcb2200d0e 100644
--- a/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
+++ b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile
@@ -75,7 +75,6 @@
Properties
- connection.login.keychain=false
s3.login.enable=true
s3.storage.class.options=STANDARD INTELLIGENT_TIERING STANDARD_IA ONEZONE_IA REDUCED_REDUNDANCY
GLACIER GLACIER_IR DEEP_ARCHIVE
diff --git a/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java b/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java
index 081b682103d..37f17ba9344 100644
--- a/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java
+++ b/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java
@@ -23,11 +23,9 @@
@AutoService(Protocol.class)
public class S3LoginProtocol extends S3Protocol {
- public static final String IDENTIFIER = "s3-login";
-
@Override
public String getIdentifier() {
- return IDENTIFIER;
+ return "s3-login";
}
@Override
diff --git a/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java b/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
index 1aec532d370..4fd9e42f678 100644
--- a/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
+++ b/s3/src/main/java/ch/cyberduck/core/s3/S3Session.java
@@ -197,7 +197,7 @@ protected String getRestMetadataPrefix() {
@Override
protected RequestEntityRestStorageService connect(final ProxyFinder proxy, final HostKeyCallback hostkey, final LoginCallback prompt, final CancelCallback cancel) throws BackgroundException {
final HttpClientBuilder configuration = builder.build(proxy, this, prompt);
- authentication = this.configureCredentialsStrategy(configuration, prompt, cancel);
+ authentication = this.configureCredentialsStrategy(configuration, prompt);
log.debug("Configured authentication strategy {}", authentication);
configuration.setServiceUnavailableRetryStrategy(new CustomServiceUnavailableRetryStrategy(host,
new S3AuthenticationResponseInterceptor(authentication)));
@@ -266,11 +266,10 @@ public void process(final HttpRequest request, final HttpContext context) {
}
protected S3CredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration,
- final LoginCallback prompt,
- final CancelCallback cancel) throws BackgroundException {
+ final LoginCallback prompt) throws BackgroundException {
if(preferences.getBoolean("s3.login.enable")) {
log.debug("Configure AWS Console Sign-In");
- return new AWSConsoleLoginCredentialsStrategy(host, cancel);
+ return new AWSConsoleLoginCredentialsStrategy(configuration.build(), host, prompt);
}
if(host.getProtocol().isOAuthConfigurable()) {
if(host.getProtocol().getOAuthScopes().contains(IdentityCenterCredentialsStrategy.SSO_ACCOUNT_ACCESS_SCOPE)) {
@@ -366,9 +365,7 @@ public void login(final LoginCallback prompt, final CancelCallback cancel) throw
if(!credentials.isAnonymousLogin()) {
// Returns details about the IAM user or role whose credentials are used to call the operation.
// No permissions are required to perform this operation.
- if(!preferences.getBoolean("s3.login.enable")) {
- new STSAuthorizationService(host, trust, key, prompt).getCallerIdentity(credentials);
- }
+ new STSAuthorizationService(host, trust, key, prompt).getCallerIdentity(credentials);
return;
}
}
diff --git a/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
index fc313478088..308ff96f32f 100644
--- a/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
+++ b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
@@ -16,167 +16,256 @@
*/
import ch.cyberduck.core.Credentials;
-import ch.cyberduck.core.Factory;
+import ch.cyberduck.core.DefaultIOExceptionMappingService;
import ch.cyberduck.core.Host;
+import ch.cyberduck.core.LoginCallback;
+import ch.cyberduck.core.PasswordStore;
+import ch.cyberduck.core.PasswordStoreFactory;
import ch.cyberduck.core.TemporaryAccessTokens;
+import ch.cyberduck.core.exception.AccessDeniedException;
import ch.cyberduck.core.exception.BackgroundException;
import ch.cyberduck.core.exception.LoginCanceledException;
import ch.cyberduck.core.exception.LoginFailureException;
-import ch.cyberduck.core.io.StreamGobbler;
+import ch.cyberduck.core.http.DefaultHttpResponseExceptionMappingService;
+import ch.cyberduck.core.oauth.LoopbackOAuth2AuthorizationCodeProvider;
import ch.cyberduck.core.s3.S3CredentialsStrategy;
-import ch.cyberduck.core.threading.CancelCallback;
-import org.apache.commons.io.IOUtils;
+import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.HttpResponseException;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.AbstractResponseHandler;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
import java.io.IOException;
-import java.io.InputStream;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.time.OffsetDateTime;
-import java.time.format.DateTimeParseException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
+import java.security.SecureRandom;
+import java.text.ParseException;
+import java.util.Base64;
+import java.util.Date;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.exceptions.JWTDecodeException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
-/**
- * Temporary S3 credentials exported by an AWS CLI browser-login profile.
- */
public class AWSConsoleLoginCredentialsStrategy implements S3CredentialsStrategy {
+ private static final Logger log = LogManager.getLogger(AWSConsoleLoginCredentialsStrategy.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
-
- static final String DEFAULT_REGION = "us-east-1";
- static final String PROFILE_PREFIX = "cyberduck";
+ private static final SecureRandom RANDOM = new SecureRandom();
+ static final String CLIENT_ID = "arn:aws:signin:::devtools/same-device";
static final String IDENTITY_PROPERTY = "s3.login.identity";
+ private static final String SERVICE = "AWS Console Sign-In";
+ private final HttpClient client;
private final Host host;
- private final CancelCallback cancel;
- private final String profile;
+ private final LoginCallback prompt;
+ private final PasswordStore store;
+ private final String endpoint;
+
private TemporaryAccessTokens tokens = TemporaryAccessTokens.EMPTY;
+ private String refreshToken;
+ private ECKey privateKey;
+ private boolean loaded;
+
+ public AWSConsoleLoginCredentialsStrategy(final HttpClient client, final Host host, final LoginCallback prompt) {
+ this(client, host, prompt, PasswordStoreFactory.get());
+ }
- public AWSConsoleLoginCredentialsStrategy(final Host host, final CancelCallback cancel) {
+ protected AWSConsoleLoginCredentialsStrategy(final HttpClient client, final Host host,
+ final LoginCallback prompt, final PasswordStore store) {
+ this.client = client;
this.host = host;
- this.cancel = cancel;
- this.profile = String.format("%s-%s", PROFILE_PREFIX, host.getUuid());
- host.setCredentials(new Credentials().setSaved(false));
+ this.prompt = prompt;
+ this.store = store;
+ final String region = StringUtils.defaultIfBlank(host.getRegion(), "us-east-1");
+ if(!StringUtils.containsOnly(region, "abcdefghijklmnopqrstuvwxyz0123456789-")) {
+ throw new IllegalArgumentException("Invalid AWS region");
+ }
+ this.endpoint = String.format("https://%s.signin.aws.amazon.com", region);
}
@Override
public synchronized Credentials get() throws BackgroundException {
if(tokens.isExpired()) {
- Result result = this.export();
- if(!result.success()) {
- final Result login = this.execute("login", "--profile", profile, "--region",
- StringUtils.defaultIfBlank(host.getRegion(), DEFAULT_REGION),
- "--no-cli-auto-prompt", "--no-cli-pager");
- if(!login.success()) {
- throw failure(login.error);
- }
- result = this.export();
+ this.load();
+ if(StringUtils.isBlank(refreshToken) || null == privateKey) {
+ tokens = this.authorize();
}
- if(!result.success()) {
- throw failure(result.error);
+ else {
+ try {
+ tokens = this.refresh();
+ }
+ catch(LoginFailureException e) {
+ log.warn("AWS sign-in session expired for {}", host);
+ tokens = this.authorize();
+ }
}
- final TemporaryAccessTokens refreshed = parse(result.output);
- this.validateIdentity();
- tokens = refreshed;
}
return new Credentials().setTokens(tokens).setSaved(false);
}
- private Result export() throws BackgroundException {
- return this.execute("configure", "export-credentials", "--profile", profile, "--region",
- StringUtils.defaultIfBlank(host.getRegion(), DEFAULT_REGION), "--format", "process",
- "--no-cli-auto-prompt", "--no-cli-pager");
+ protected TemporaryAccessTokens authorize() throws BackgroundException {
+ final ECKey key;
+ try {
+ key = new ECKeyGenerator(Curve.P_256).generate();
+ }
+ catch(JOSEException e) {
+ throw failure(e);
+ }
+ final byte[] random = new byte[48];
+ RANDOM.nextBytes(random);
+ final String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
+ final String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ DigestUtils.sha256(verifier.getBytes(StandardCharsets.US_ASCII)));
+ final String state = UUID.randomUUID().toString();
+ final AtomicReference redirectUri = new AtomicReference<>();
+ final String code = new LoopbackOAuth2AuthorizationCodeProvider().prompt(host, prompt,
+ redirect -> {
+ redirectUri.set(redirect);
+ return new URIBuilder(URI.create(String.format("%s/v1/authorize", endpoint)))
+ .addParameter("response_type", "code")
+ .addParameter("client_id", CLIENT_ID)
+ .addParameter("state", state)
+ .addParameter("code_challenge", challenge)
+ .addParameter("code_challenge_method", "SHA-256")
+ .addParameter("scope", "openid")
+ .addParameter("redirect_uri", redirect).toString();
+ }, state);
+ if(StringUtils.isBlank(code)) {
+ throw new LoginCanceledException();
+ }
+ final ObjectNode request = MAPPER.createObjectNode().put("clientId", CLIENT_ID)
+ .put("grantType", "authorization_code").put("code", code)
+ .put("codeVerifier", verifier).put("redirectUri", redirectUri.get());
+ return this.accept(this.exchange(request, key), key, true);
}
- private void validateIdentity() throws BackgroundException {
- final Result result = this.execute("sts", "get-caller-identity", "--profile", profile, "--region",
- StringUtils.defaultIfBlank(host.getRegion(), DEFAULT_REGION), "--output", "json",
- "--no-cli-auto-prompt", "--no-cli-pager");
- if(!result.success()) {
- throw failure(result.error);
- }
+ private TemporaryAccessTokens refresh() throws BackgroundException {
+ final ObjectNode request = MAPPER.createObjectNode().put("clientId", CLIENT_ID)
+ .put("grantType", "refresh_token").put("refreshToken", refreshToken);
+ return this.accept(this.exchange(request, privateKey), privateKey, false);
+ }
+
+ private JsonNode exchange(final ObjectNode body, final ECKey key) throws BackgroundException {
+ final String url = String.format("%s/v1/token", endpoint);
+ final HttpPost request = new HttpPost(url);
try {
- final JsonNode identity = MAPPER.readTree(result.output);
- if(null == identity || !identity.isObject()) {
- throw failure();
+ request.setHeader("DPoP", proof(key, url));
+ request.setEntity(new StringEntity(MAPPER.writeValueAsString(body), ContentType.APPLICATION_JSON));
+ return client.execute(request, new AbstractResponseHandler() {
+ @Override
+ public JsonNode handleEntity(final HttpEntity entity) throws IOException {
+ return MAPPER.readTree(entity.getContent());
+ }
+ });
+ }
+ catch(HttpResponseException e) {
+ if(e.getStatusCode() >= 400 && e.getStatusCode() < 500 && e.getStatusCode() != 429) {
+ throw failure(e);
}
- validateIdentity(host, identity.path("Account").asText(), identity.path("Arn").asText());
+ throw new DefaultHttpResponseExceptionMappingService().map(e);
}
catch(IOException e) {
- throw failure(e);
+ throw new DefaultIOExceptionMappingService().map(e);
}
}
- protected Result execute(final String... arguments) throws BackgroundException {
- final List command = new ArrayList<>();
- command.add(executable());
- Collections.addAll(command, arguments);
- final Process process;
- try {
- process = new ProcessBuilder(command).start();
+ private TemporaryAccessTokens accept(final JsonNode response, final ECKey key,
+ final boolean validateIdentity) throws LoginFailureException {
+ if(null == response) {
+ throw failure();
}
- catch(IOException e) {
- throw failure(e);
+ final JsonNode access = response.path("accessToken");
+ final String accessKey = access.path("accessKeyId").asText();
+ final String secretKey = access.path("secretAccessKey").asText();
+ final String sessionToken = access.path("sessionToken").asText();
+ final String refresh = response.path("refreshToken").asText();
+ final long expires = response.path("expiresIn").asLong(-1L);
+ if(StringUtils.isAnyBlank(accessKey, secretKey, sessionToken, refresh) || expires <= 0L) {
+ throw failure();
}
- try(InputStream output = new StreamGobbler(process.getInputStream());
- InputStream error = new StreamGobbler(process.getErrorStream())) {
- // Browser login must never wait on an invisible terminal prompt.
- process.getOutputStream().close();
- while(!process.waitFor(250L, TimeUnit.MILLISECONDS)) {
- cancel.verify();
- }
- return new Result(process.exitValue(),
- IOUtils.toString(output, StandardCharsets.UTF_8),
- IOUtils.toString(error, StandardCharsets.UTF_8));
+ if(validateIdentity) {
+ validateIdentity(host, response.path("idToken").asText());
}
- catch(InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new LoginCanceledException(e);
+ refreshToken = refresh;
+ privateKey = key;
+ this.save();
+ return new TemporaryAccessTokens(accessKey, secretKey, sessionToken,
+ System.currentTimeMillis() + expires * 1000L - 5L * 60L * 1000L);
+ }
+
+ private void load() {
+ if(loaded || !host.getCredentials().isSaved()) {
+ loaded = true;
+ return;
}
- catch(IOException e) {
- throw failure(e);
+ loaded = true;
+ try {
+ refreshToken = store.getPassword(SERVICE, this.account("Refresh Token"));
+ final String key = store.getPassword(SERVICE, this.account("DPoP Private Key"));
+ if(StringUtils.isBlank(refreshToken) || StringUtils.isBlank(key)) {
+ refreshToken = null;
+ return;
+ }
+ privateKey = ECKey.parse(key);
}
- finally {
- process.destroy();
+ catch(AccessDeniedException | ParseException e) {
+ log.warn("Failure loading AWS sign-in session for {}", host);
+ refreshToken = null;
+ privateKey = null;
}
}
- private static TemporaryAccessTokens parse(final String output) throws LoginFailureException {
+ private void save() {
+ if(!host.getCredentials().isSaved()) {
+ return;
+ }
try {
- final JsonNode value = MAPPER.readTree(output);
- if(null == value || !value.isObject()) {
- throw failure();
- }
- final String accessKey = value.path("AccessKeyId").asText();
- final String secretKey = value.path("SecretAccessKey").asText();
- final String sessionToken = value.path("SessionToken").asText();
- final long expiration = OffsetDateTime.parse(value.path("Expiration").asText()).toInstant().toEpochMilli();
- if(StringUtils.isAnyBlank(accessKey, secretKey, sessionToken) || expiration <= System.currentTimeMillis()) {
- throw failure();
- }
- return new TemporaryAccessTokens(accessKey, secretKey, sessionToken, expiration);
+ store.addPassword(SERVICE, this.account("DPoP Private Key"), privateKey.toJSONString());
+ store.addPassword(SERVICE, this.account("Refresh Token"), refreshToken);
}
- catch(IOException | DateTimeParseException e) {
- throw failure(e);
+ catch(AccessDeniedException e) {
+ log.warn("Failure saving AWS sign-in session for {}", host);
}
}
- private static String executable() {
- if(Factory.Platform.Name.mac.equals(Factory.Platform.getDefault())) {
- for(String candidate : new String[]{"/usr/local/bin/aws", "/opt/homebrew/bin/aws"}) {
- if(Files.isExecutable(Paths.get(candidate))) {
- return candidate;
- }
- }
+ private String account(final String secret) {
+ return String.format("%s %s", host.getUuid(), secret);
+ }
+
+ private static String proof(final ECKey key, final String url) throws LoginFailureException {
+ try {
+ final SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256)
+ .type(new JOSEObjectType("dpop+jwt")).jwk(key.toPublicJWK()).build(),
+ new JWTClaimsSet.Builder().claim("htm", "POST").claim("htu", url)
+ .issueTime(new Date()).jwtID(UUID.randomUUID().toString()).build());
+ jwt.sign(new ECDSASigner(key));
+ return jwt.serialize();
+ }
+ catch(JOSEException e) {
+ throw failure(e);
}
- return Factory.Platform.Name.windows.equals(Factory.Platform.getDefault()) ? "aws.exe" : "aws";
}
private static String identity(final String account, final String arn) {
@@ -188,6 +277,16 @@ private static String identity(final String account, final String arn) {
return String.format("%s/%s", account, resource);
}
+ static void validateIdentity(final Host host, final String idToken) throws LoginFailureException {
+ try {
+ final String arn = JWT.decode(idToken).getSubject();
+ validateIdentity(host, StringUtils.substringBetween(arn, "::", ":"), arn);
+ }
+ catch(JWTDecodeException e) {
+ throw failure(e);
+ }
+ }
+
static void validateIdentity(final Host host, final String account, final String arn) throws LoginFailureException {
if(StringUtils.isAnyBlank(account, arn)) {
throw failure();
@@ -206,36 +305,10 @@ static void validateIdentity(final Host host, final String account, final String
}
private static LoginFailureException failure() {
- return failure((Throwable) null);
+ return failure(null);
}
private static LoginFailureException failure(final Throwable cause) {
- return new LoginFailureException(
- "AWS browser sign-in failed. AWS CLI 2.32 or later is required.",
- cause);
- }
-
- private static LoginFailureException failure(final String detail) {
- if(StringUtils.isBlank(detail)) {
- return failure();
- }
- return new LoginFailureException(String.format(
- "AWS browser sign-in failed: %s", StringUtils.abbreviate(StringUtils.trim(detail), 1000)));
- }
-
- protected static final class Result {
- private final int status;
- private final String output;
- private final String error;
-
- protected Result(final int status, final String output, final String error) {
- this.status = status;
- this.output = output;
- this.error = error;
- }
-
- private boolean success() {
- return status == 0;
- }
+ return new LoginFailureException("AWS browser sign-in failed.", cause);
}
}
diff --git a/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java b/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java
index 66b0253cbe5..59eeca41e57 100644
--- a/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java
+++ b/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java
@@ -16,34 +16,25 @@
*/
import ch.cyberduck.core.Credentials;
+import ch.cyberduck.core.DisabledPasswordStore;
import ch.cyberduck.core.Host;
+import ch.cyberduck.core.TemporaryAccessTokens;
import ch.cyberduck.core.exception.LoginFailureException;
import ch.cyberduck.core.s3.S3LoginProtocol;
-import ch.cyberduck.core.threading.CancelCallback;
import org.junit.Test;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Queue;
-
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
-import static org.junit.Assert.assertTrue;
public class AWSConsoleLoginCredentialsStrategyTest {
@Test
- public void testExportAndMemoryCache() throws Exception {
- final Host host = new Host(new S3LoginProtocol()).setUuid("bookmark");
- host.getCredentials().setSaved(true);
- final TestStrategy strategy = new TestStrategy(host,
- new AWSConsoleLoginCredentialsStrategy.Result(0, credentials(), ""),
- new AWSConsoleLoginCredentialsStrategy.Result(0,
- identity("arn:aws:iam::123456789012:user/alice"), ""));
+ public void testMemoryCache() throws Exception {
+ final Host host = new Host(new S3LoginProtocol());
+ host.getCredentials().setSaved(false);
+ final TestStrategy strategy = new TestStrategy(host);
final Credentials first = strategy.get();
final Credentials second = strategy.get();
@@ -53,54 +44,7 @@ public void testExportAndMemoryCache() throws Exception {
assertEquals("session", first.getTokens().getSessionToken());
assertFalse(first.isSaved());
assertFalse(second.isSaved());
- assertFalse(host.getCredentials().isSaved());
- assertEquals(2, strategy.commands.size());
- assertEquals(Arrays.asList("configure", "export-credentials", "--profile", "cyberduck-bookmark",
- "--region", "us-east-1", "--format", "process", "--no-cli-auto-prompt", "--no-cli-pager"),
- strategy.commands.get(0));
- assertEquals(Arrays.asList("sts", "get-caller-identity", "--profile", "cyberduck-bookmark",
- "--region", "us-east-1", "--output", "json", "--no-cli-auto-prompt", "--no-cli-pager"),
- strategy.commands.get(1));
- }
-
- @Test
- public void testLoginWhenExportFails() throws Exception {
- final TestStrategy strategy = new TestStrategy(
- new Host(new S3LoginProtocol()).setRegion("eu-west-1").setUuid("bookmark"),
- new AWSConsoleLoginCredentialsStrategy.Result(1, "No credentials", ""),
- new AWSConsoleLoginCredentialsStrategy.Result(0, "Login succeeded", ""),
- new AWSConsoleLoginCredentialsStrategy.Result(0, credentials(), ""),
- new AWSConsoleLoginCredentialsStrategy.Result(0,
- identity("arn:aws:iam::123456789012:user/alice"), ""));
-
- strategy.get();
-
- assertEquals(4, strategy.commands.size());
- assertEquals(Arrays.asList("configure", "export-credentials", "--profile", "cyberduck-bookmark",
- "--region", "eu-west-1", "--format", "process", "--no-cli-auto-prompt", "--no-cli-pager"),
- strategy.commands.get(0));
- assertEquals(Arrays.asList("login", "--profile", "cyberduck-bookmark", "--region", "eu-west-1",
- "--no-cli-auto-prompt", "--no-cli-pager"), strategy.commands.get(1));
- }
-
- @Test
- public void testShowLoginErrorWithoutCredentialOutput() {
- final TestStrategy strategy = new TestStrategy(new Host(new S3LoginProtocol()),
- new AWSConsoleLoginCredentialsStrategy.Result(1, "credentials", ""),
- new AWSConsoleLoginCredentialsStrategy.Result(1, "sign-in URL", "permission denied"));
-
- final LoginFailureException failure = assertThrows(LoginFailureException.class, strategy::get);
- assertTrue(failure.getDetail(false).contains("permission denied"));
- assertFalse(failure.getDetail(false).contains("credentials"));
- }
-
- @Test
- public void testRejectPermanentCredentials() {
- final TestStrategy strategy = new TestStrategy(new Host(new S3LoginProtocol()),
- new AWSConsoleLoginCredentialsStrategy.Result(0,
- "{\"Version\":1,\"AccessKeyId\":\"key\",\"SecretAccessKey\":\"secret\"}", ""));
-
- assertThrows(LoginFailureException.class, strategy::get);
+ assertEquals(1, strategy.authorizations);
}
@Test
@@ -127,28 +71,17 @@ public void testRoleIdentityDropsSessionName() throws Exception {
host.getProperty(AWSConsoleLoginCredentialsStrategy.IDENTITY_PROPERTY));
}
- private static String credentials() {
- return "{\"Version\":1,\"AccessKeyId\":\"ASIAEXAMPLE\",\"SecretAccessKey\":\"secret\","
- + "\"SessionToken\":\"session\",\"Expiration\":\"2099-01-01T00:00:00+00:00\"}";
- }
-
- private static String identity(final String arn) {
- return String.format("{\"Account\":\"123456789012\",\"Arn\":\"%s\"}", arn);
- }
-
private static final class TestStrategy extends AWSConsoleLoginCredentialsStrategy {
- private final Queue results = new ArrayDeque<>();
- private final List> commands = new ArrayList<>();
+ private int authorizations;
- private TestStrategy(final Host host, final Result... results) {
- super(host, CancelCallback.noop);
- this.results.addAll(Arrays.asList(results));
+ private TestStrategy(final Host host) {
+ super(null, host, null, new DisabledPasswordStore());
}
@Override
- protected Result execute(final String... arguments) {
- commands.add(Arrays.asList(arguments));
- return results.remove();
+ protected TemporaryAccessTokens authorize() {
+ authorizations++;
+ return new TemporaryAccessTokens("ASIAEXAMPLE", "secret", "session", Long.MAX_VALUE);
}
}
}
diff --git a/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java b/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java
index 0965f7c8721..845bbb0845a 100644
--- a/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java
+++ b/spectra/src/main/java/ch/cyberduck/core/spectra/SpectraSession.java
@@ -57,9 +57,7 @@ public SpectraCredentialsStrategy getAuthentication() {
}
@Override
- protected SpectraCredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration,
- final LoginCallback prompt,
- final CancelCallback cancel) {
+ protected SpectraCredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration, final LoginCallback prompt) {
return new SpectraCredentialsStrategy(host.getCredentials());
}
diff --git a/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs b/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
index e4b0839bd42..2b70e378116 100644
--- a/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
+++ b/windows/src/main/csharp/ch/cyberduck/ui/controller/ConnectionController.cs
@@ -46,6 +46,8 @@ private ConnectionController(Host bookmark, LoginOptions options) : this(bookmar
private ConnectionController(Host bookmark,
LoginInputValidator validator, LoginOptions options) : base(bookmark, validator, options)
{
+ View.SavePasswordEnabled = _options.keychain();
+ View.SavePasswordChecked = bookmark.getCredentials().isSaved();
View.ChangedSavePasswordCheckboxEvent += View_ChangedSavePasswordCheckboxEvent;
View.ChangedPasswordEvent += delegate { _host.getCredentials().setPassword(View.Password); };
}
@@ -73,13 +75,6 @@ private void View_ChangedSavePasswordCheckboxEvent()
_host.getCredentials().setSaved(View.SavePasswordChecked);
}
- protected override void Update()
- {
- base.Update();
- View.SavePasswordEnabled = _options.keychain();
- View.SavePasswordChecked = _options.keychain() && _host.getCredentials().isSaved();
- }
-
protected override void ItemChanged()
{
//
From 9b087fb37b2602132bbad632f8d5b9e559ee2c3f Mon Sep 17 00:00:00 2001
From: whatever60 <57242693+whatever60@users.noreply.github.com>
Date: Mon, 3 Aug 2026 00:36:16 -0700
Subject: [PATCH 7/7] Clamp AWS sign-in expiry buffer
---
.../core/signin/AWSConsoleLoginCredentialsStrategy.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
index 308ff96f32f..77a14a951f7 100644
--- a/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
+++ b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java
@@ -212,7 +212,7 @@ private TemporaryAccessTokens accept(final JsonNode response, final ECKey key,
privateKey = key;
this.save();
return new TemporaryAccessTokens(accessKey, secretKey, sessionToken,
- System.currentTimeMillis() + expires * 1000L - 5L * 60L * 1000L);
+ System.currentTimeMillis() + (expires - Math.min(5L * 60L, expires / 2L)) * 1000L);
}
private void load() {