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/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..6fcb2200d0e --- /dev/null +++ b/profiles/default/Amazon S3 (AWS Console Sign-In).cyberduckprofile @@ -0,0 +1,84 @@ + + + + + + + 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.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 new file mode 100644 index 00000000000..37f17ba9344 --- /dev/null +++ b/s3/src/main/java/ch/cyberduck/core/s3/S3LoginProtocol.java @@ -0,0 +1,49 @@ +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 { + + @Override + public String getIdentifier() { + return "s3-login"; + } + + @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..4fd9e42f678 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; @@ -266,6 +267,10 @@ public void process(final HttpRequest request, final HttpContext context) { protected S3CredentialsStrategy configureCredentialsStrategy(final HttpClientBuilder configuration, final LoginCallback prompt) throws BackgroundException { + if(preferences.getBoolean("s3.login.enable")) { + log.debug("Configure AWS Console Sign-In"); + return new AWSConsoleLoginCredentialsStrategy(configuration.build(), host, prompt); + } if(host.getProtocol().isOAuthConfigurable()) { if(host.getProtocol().getOAuthScopes().contains(IdentityCenterCredentialsStrategy.SSO_ACCOUNT_ACCESS_SCOPE)) { log.debug("Configure SSO"); 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..77a14a951f7 --- /dev/null +++ b/s3/src/main/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategy.java @@ -0,0 +1,314 @@ +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.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.http.DefaultHttpResponseExceptionMappingService; +import ch.cyberduck.core.oauth.LoopbackOAuth2AuthorizationCodeProvider; +import ch.cyberduck.core.s3.S3CredentialsStrategy; + +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.net.URI; +import java.nio.charset.StandardCharsets; +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; + +public class AWSConsoleLoginCredentialsStrategy implements S3CredentialsStrategy { + private static final Logger log = LogManager.getLogger(AWSConsoleLoginCredentialsStrategy.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + 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 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()); + } + + protected AWSConsoleLoginCredentialsStrategy(final HttpClient client, final Host host, + final LoginCallback prompt, final PasswordStore store) { + this.client = client; + this.host = host; + 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()) { + this.load(); + if(StringUtils.isBlank(refreshToken) || null == privateKey) { + tokens = this.authorize(); + } + else { + try { + tokens = this.refresh(); + } + catch(LoginFailureException e) { + log.warn("AWS sign-in session expired for {}", host); + tokens = this.authorize(); + } + } + } + return new Credentials().setTokens(tokens).setSaved(false); + } + + 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 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 { + 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); + } + throw new DefaultHttpResponseExceptionMappingService().map(e); + } + catch(IOException e) { + throw new DefaultIOExceptionMappingService().map(e); + } + } + + private TemporaryAccessTokens accept(final JsonNode response, final ECKey key, + final boolean validateIdentity) throws LoginFailureException { + if(null == response) { + throw failure(); + } + 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(); + } + if(validateIdentity) { + validateIdentity(host, response.path("idToken").asText()); + } + refreshToken = refresh; + privateKey = key; + this.save(); + return new TemporaryAccessTokens(accessKey, secretKey, sessionToken, + System.currentTimeMillis() + (expires - Math.min(5L * 60L, expires / 2L)) * 1000L); + } + + private void load() { + if(loaded || !host.getCredentials().isSaved()) { + loaded = true; + return; + } + 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); + } + catch(AccessDeniedException | ParseException e) { + log.warn("Failure loading AWS sign-in session for {}", host); + refreshToken = null; + privateKey = null; + } + } + + private void save() { + if(!host.getCredentials().isSaved()) { + return; + } + try { + store.addPassword(SERVICE, this.account("DPoP Private Key"), privateKey.toJSONString()); + store.addPassword(SERVICE, this.account("Refresh Token"), refreshToken); + } + catch(AccessDeniedException e) { + log.warn("Failure saving AWS sign-in session for {}", host); + } + } + + 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); + } + } + + 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 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(); + } + 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(null); + } + + private static LoginFailureException failure(final Throwable cause) { + 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 new file mode 100644 index 00000000000..59eeca41e57 --- /dev/null +++ b/s3/src/test/java/ch/cyberduck/core/signin/AWSConsoleLoginCredentialsStrategyTest.java @@ -0,0 +1,87 @@ +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.DisabledPasswordStore; +import ch.cyberduck.core.Host; +import ch.cyberduck.core.TemporaryAccessTokens; +import ch.cyberduck.core.exception.LoginFailureException; +import ch.cyberduck.core.s3.S3LoginProtocol; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +public class AWSConsoleLoginCredentialsStrategyTest { + + @Test + 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(); + + assertEquals("ASIAEXAMPLE", first.getTokens().getAccessKeyId()); + assertEquals("secret", first.getTokens().getSecretAccessKey()); + assertEquals("session", first.getTokens().getSessionToken()); + assertFalse(first.isSaved()); + assertFalse(second.isSaved()); + assertEquals(1, strategy.authorizations); + } + + @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 final class TestStrategy extends AWSConsoleLoginCredentialsStrategy { + private int authorizations; + + private TestStrategy(final Host host) { + super(null, host, null, new DisabledPasswordStore()); + } + + @Override + protected TemporaryAccessTokens authorize() { + authorizations++; + return new TemporaryAccessTokens("ASIAEXAMPLE", "secret", "session", Long.MAX_VALUE); + } + } +}