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() {