mirror of https://github.com/apache/jclouds.git
added AWS temporary security credentials and integrated with FormSigner
This commit is contained in:
parent
847561ee00
commit
25ce398a44
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.jclouds.aws.domain;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import org.jclouds.domain.Credentials;
|
||||
|
||||
import com.google.common.base.Objects;
|
||||
|
||||
/**
|
||||
* AWS credentials for API authentication.
|
||||
*
|
||||
* @see <a href=
|
||||
* "http://docs.aws.amazon.com/STS/latest/APIReference/API_Credentials.html"
|
||||
* />
|
||||
*
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
public final class TemporaryCredentials extends Credentials {
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public Builder toBuilder() {
|
||||
return builder().from(this);
|
||||
}
|
||||
|
||||
public final static class Builder extends Credentials.Builder<TemporaryCredentials> {
|
||||
private String accessKeyId;
|
||||
private String secretAccessKey;
|
||||
private String sessionToken;
|
||||
private Date expiration;
|
||||
|
||||
@Override
|
||||
public Builder identity(String identity) {
|
||||
return accessKeyId(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Builder credential(String credential) {
|
||||
return secretAccessKey(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see TemporaryCredentials#getAccessKeyId()
|
||||
*/
|
||||
public Builder accessKeyId(String accessKeyId) {
|
||||
this.accessKeyId = accessKeyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see TemporaryCredentials#getSecretAccessKey()
|
||||
*/
|
||||
public Builder secretAccessKey(String secretAccessKey) {
|
||||
this.secretAccessKey = secretAccessKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see TemporaryCredentials#getSessionToken()
|
||||
*/
|
||||
public Builder sessionToken(String sessionToken) {
|
||||
this.sessionToken = sessionToken;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see TemporaryCredentials#getExpiration()
|
||||
*/
|
||||
public Builder expiration(Date expiration) {
|
||||
this.expiration = expiration;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporaryCredentials build() {
|
||||
return new TemporaryCredentials(accessKeyId, secretAccessKey, sessionToken, expiration);
|
||||
}
|
||||
|
||||
public Builder from(TemporaryCredentials in) {
|
||||
return this.accessKeyId(in.identity).secretAccessKey(in.credential).sessionToken(in.sessionToken)
|
||||
.expiration(in.expiration);
|
||||
}
|
||||
}
|
||||
|
||||
private final String sessionToken;
|
||||
private final Date expiration;
|
||||
|
||||
private TemporaryCredentials(String accessKeyId, String secretAccessKey, String sessionToken, Date expiration) {
|
||||
super(checkNotNull(accessKeyId, "accessKeyId"), checkNotNull(secretAccessKey, "secretAccessKey for %s",
|
||||
accessKeyId));
|
||||
this.sessionToken = checkNotNull(sessionToken, "sessionToken for %s", accessKeyId);
|
||||
this.expiration = checkNotNull(expiration, "expiration for %s", accessKeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* AccessKeyId ID that identifies the temporary credentials.
|
||||
*/
|
||||
public String getAccessKeyId() {
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Secret Access Key to sign requests.
|
||||
*/
|
||||
public String getSecretAccessKey() {
|
||||
return credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* The security token that users must pass to the service API to use the
|
||||
* temporary credentials.
|
||||
*/
|
||||
public String getSessionToken() {
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* The date on which these credentials expire.
|
||||
*/
|
||||
public Date getExpiration() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(identity, credential, sessionToken, expiration);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj)
|
||||
return true;
|
||||
if (obj == null)
|
||||
return false;
|
||||
if (getClass() != obj.getClass())
|
||||
return false;
|
||||
TemporaryCredentials other = (TemporaryCredentials) obj;
|
||||
return Objects.equal(this.identity, other.identity) && Objects.equal(this.credential, other.credential)
|
||||
&& Objects.equal(this.sessionToken, other.sessionToken) && Objects.equal(this.expiration, other.expiration);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return Objects.toStringHelper(this).add("accessKeyId", identity).add("sessionToken", sessionToken)
|
||||
.add("expiration", expiration).toString();
|
||||
}
|
||||
|
||||
}
|
|
@ -47,6 +47,7 @@ import javax.inject.Singleton;
|
|||
import javax.ws.rs.core.HttpHeaders;
|
||||
|
||||
import org.jclouds.Constants;
|
||||
import org.jclouds.aws.domain.TemporaryCredentials;
|
||||
import org.jclouds.crypto.Crypto;
|
||||
import org.jclouds.date.TimeStamp;
|
||||
import org.jclouds.domain.Credentials;
|
||||
|
@ -114,9 +115,17 @@ public class FormSigner implements HttpRequestFilter, RequestSigner {
|
|||
String signature = sign(stringToSign);
|
||||
addSignature(decodedParams, signature);
|
||||
request = setPayload(request, decodedParams);
|
||||
Credentials current = creds.get();
|
||||
if (current instanceof TemporaryCredentials) {
|
||||
request = replaceSecurityTokenHeader(request, TemporaryCredentials.class.cast(current));
|
||||
}
|
||||
utils.logRequest(signatureLog, request, "<<");
|
||||
return request;
|
||||
}
|
||||
|
||||
HttpRequest replaceSecurityTokenHeader(HttpRequest request, TemporaryCredentials current) {
|
||||
return request.toBuilder().replaceHeader("SecurityToken", current.getSessionToken()).build();
|
||||
}
|
||||
|
||||
HttpRequest setPayload(HttpRequest request, Multimap<String, String> decodedParams) {
|
||||
String queryLine = buildQueryLine(decodedParams);
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.jclouds.aws.xml;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import org.jclouds.aws.domain.TemporaryCredentials;
|
||||
import org.jclouds.date.DateService;
|
||||
import org.jclouds.http.functions.ParseSax;
|
||||
import org.jclouds.util.SaxUtils;
|
||||
|
||||
/**
|
||||
* @see <a href=
|
||||
* "http://docs.aws.amazon.com/STS/latest/APIReference/API_Credentials.html"
|
||||
* />
|
||||
*
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
public class TemporaryCredentialsHandler extends ParseSax.HandlerForGeneratedRequestWithResult<TemporaryCredentials> {
|
||||
private final DateService dateService;
|
||||
|
||||
@Inject
|
||||
protected TemporaryCredentialsHandler(DateService dateService) {
|
||||
this.dateService = dateService;
|
||||
}
|
||||
|
||||
private StringBuilder currentText = new StringBuilder();
|
||||
private TemporaryCredentials.Builder builder = TemporaryCredentials.builder();
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public TemporaryCredentials getResult() {
|
||||
try {
|
||||
return builder.build();
|
||||
} finally {
|
||||
builder = TemporaryCredentials.builder();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void endElement(String uri, String name, String qName) {
|
||||
if (qName.equals("AccessKeyId")) {
|
||||
builder.accessKeyId(SaxUtils.currentOrNull(currentText));
|
||||
} else if (qName.equals("SecretAccessKey")) {
|
||||
builder.secretAccessKey(SaxUtils.currentOrNull(currentText));
|
||||
} else if (qName.equals("SessionToken")) {
|
||||
builder.sessionToken(SaxUtils.currentOrNull(currentText));
|
||||
} else if (qName.equals("Expiration")) {
|
||||
builder.expiration(dateService.iso8601DateParse(SaxUtils.currentOrNull(currentText)));
|
||||
}
|
||||
currentText = new StringBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void characters(char ch[], int start, int length) {
|
||||
currentText.append(ch, start, length);
|
||||
}
|
||||
|
||||
}
|
|
@ -17,13 +17,16 @@
|
|||
* under the License.
|
||||
*/
|
||||
package org.jclouds.aws.filters;
|
||||
|
||||
import static org.jclouds.aws.reference.AWSConstants.PROPERTY_HEADER_TAG;
|
||||
import static org.testng.Assert.assertEquals;
|
||||
|
||||
import javax.ws.rs.core.HttpHeaders;
|
||||
|
||||
import org.jclouds.ContextBuilder;
|
||||
import org.jclouds.aws.xml.TemporaryCredentialsHandlerTest;
|
||||
import org.jclouds.date.TimeStamp;
|
||||
import org.jclouds.domain.Credentials;
|
||||
import org.jclouds.http.HttpRequest;
|
||||
import org.jclouds.http.IntegrationTestAsyncClient;
|
||||
import org.jclouds.http.IntegrationTestClient;
|
||||
|
@ -33,6 +36,7 @@ import org.jclouds.rest.RequestSigner;
|
|||
import org.jclouds.rest.internal.BaseRestApiTest.MockModule;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import com.google.common.base.Suppliers;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMultimap;
|
||||
import com.google.inject.AbstractModule;
|
||||
|
@ -45,49 +49,63 @@ import com.google.inject.name.Names;
|
|||
*
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
// NOTE:without testName, this will not call @Before* and fail w/NPE during surefire
|
||||
@Test(groups = "unit", testName = "FormSignerTest")
|
||||
// NOTE:without testName, this will not call @Before* and fail w/NPE during
|
||||
// surefire
|
||||
@Test(groups = "unit", singleThreaded = true, testName = "FormSignerTest")
|
||||
public class FormSignerTest {
|
||||
public static final Injector INJECTOR = ContextBuilder
|
||||
.newBuilder(
|
||||
AnonymousProviderMetadata.forClientMappedToAsyncClientOnEndpoint(IntegrationTestClient.class, IntegrationTestAsyncClient.class,
|
||||
"http://localhost"))
|
||||
.credentials("identity", "credential")
|
||||
.apiVersion("apiVersion")
|
||||
.modules(ImmutableList.<Module> of(new MockModule(), new NullLoggingModule(),
|
||||
new AbstractModule() {
|
||||
@Override
|
||||
protected void configure() {
|
||||
bind(RequestSigner.class).to(FormSigner.class);
|
||||
bind(String.class).annotatedWith(Names.named(PROPERTY_HEADER_TAG)).toInstance("amz");
|
||||
bind(String.class).annotatedWith(TimeStamp.class).toInstance("2009-11-08T15:54:08.897Z");
|
||||
}
|
||||
public static Injector injector(Credentials creds) {
|
||||
return ContextBuilder
|
||||
.newBuilder(
|
||||
AnonymousProviderMetadata.forClientMappedToAsyncClientOnEndpoint(IntegrationTestClient.class,
|
||||
IntegrationTestAsyncClient.class, "http://localhost"))
|
||||
.credentialsSupplier(Suppliers.<Credentials> ofInstance(creds)).apiVersion("apiVersion")
|
||||
.modules(ImmutableList.<Module> of(new MockModule(), new NullLoggingModule(), new AbstractModule() {
|
||||
@Override
|
||||
protected void configure() {
|
||||
bind(RequestSigner.class).to(FormSigner.class);
|
||||
bind(String.class).annotatedWith(Names.named(PROPERTY_HEADER_TAG)).toInstance("amz");
|
||||
bind(String.class).annotatedWith(TimeStamp.class).toInstance("2009-11-08T15:54:08.897Z");
|
||||
}
|
||||
|
||||
})).buildInjector();
|
||||
FormSigner filter = INJECTOR.getInstance(FormSigner.class);
|
||||
})).buildInjector();
|
||||
}
|
||||
|
||||
public static FormSigner filter(Credentials creds) {
|
||||
return injector(creds).getInstance(FormSigner.class);
|
||||
}
|
||||
|
||||
public static FormSigner staticCredentialsFilter = filter(new Credentials("identity", "credential"));
|
||||
|
||||
HttpRequest request = HttpRequest.builder().method("GET")
|
||||
.endpoint("http://localhost")
|
||||
.addHeader(HttpHeaders.HOST, "localhost")
|
||||
.addFormParam("Action", "DescribeImages")
|
||||
.addFormParam("ImageId.1", "ami-2bb65342").build();
|
||||
|
||||
@Test
|
||||
void testAddsSecurityToken() {
|
||||
HttpRequest filtered = filter(new TemporaryCredentialsHandlerTest().expected()).filter(request);
|
||||
assertEquals(
|
||||
filtered.getPayload().getRawContent(),
|
||||
"Action=DescribeImages&ImageId.1=ami-2bb65342&Signature=waV%2B%2BIdRwHRlnK2126CqgHHd4FZb%2B5wAeRueidjFc/M%3D&SignatureMethod=HmacSHA256&SignatureVersion=2&Timestamp=2009-11-08T15%3A54%3A08.897Z&Version=apiVersion&AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE");
|
||||
assertEquals(filtered.getFirstHeaderOrNull("SecurityToken"), "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildCanonicalizedStringSetsVersion() {
|
||||
|
||||
assertEquals(
|
||||
filter.filter(
|
||||
HttpRequest.builder()
|
||||
.method("GET")
|
||||
.endpoint("http://localhost")
|
||||
.addHeader(HttpHeaders.HOST, "localhost")
|
||||
.payload("Action=DescribeImages&ImageId.1=ami-2bb65342").build())
|
||||
.getPayload().getRawContent(),
|
||||
"Action=DescribeImages&ImageId.1=ami-2bb65342&Signature=ugnt4m2eHE7Ka/vXTr9EhKZq7bhxOfvW0y4pAEqF97w%3D&SignatureMethod=HmacSHA256&SignatureVersion=2&Timestamp=2009-11-08T15%3A54%3A08.897Z&Version=apiVersion&AWSAccessKeyId=identity");
|
||||
HttpRequest filtered = staticCredentialsFilter.filter(request);
|
||||
assertEquals(filtered.getPayload().getRawContent(),
|
||||
"Action=DescribeImages&ImageId.1=ami-2bb65342&Signature=ugnt4m2eHE7Ka/vXTr9EhKZq7bhxOfvW0y4pAEqF97w%3D&SignatureMethod=HmacSHA256&SignatureVersion=2&Timestamp=2009-11-08T15%3A54%3A08.897Z&Version=apiVersion&AWSAccessKeyId=identity");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildCanonicalizedString() {
|
||||
assertEquals(
|
||||
filter.buildCanonicalizedString(new ImmutableMultimap.Builder<String, String>().put("AWSAccessKeyId",
|
||||
"foo").put("Action", "DescribeImages").put("Expires", "2008-02-10T12:00:00Z").put("ImageId.1",
|
||||
"ami-2bb65342").put("SignatureMethod", "HmacSHA256").put("SignatureVersion", "2").put(
|
||||
"Version", "2010-06-15").build()),
|
||||
"AWSAccessKeyId=foo&Action=DescribeImages&Expires=2008-02-10T12%3A00%3A00Z&ImageId.1=ami-2bb65342&SignatureMethod=HmacSHA256&SignatureVersion=2&Version=2010-06-15");
|
||||
staticCredentialsFilter.buildCanonicalizedString(new ImmutableMultimap.Builder<String, String>()
|
||||
.put("AWSAccessKeyId", "foo").put("Action", "DescribeImages").put("Expires", "2008-02-10T12:00:00Z")
|
||||
.put("ImageId.1", "ami-2bb65342").put("SignatureMethod", "HmacSHA256").put("SignatureVersion", "2")
|
||||
.put("Version", "2010-06-15").build()),
|
||||
"AWSAccessKeyId=foo&Action=DescribeImages&Expires=2008-02-10T12%3A00%3A00Z&ImageId.1=ami-2bb65342&SignatureMethod=HmacSHA256&SignatureVersion=2&Version=2010-06-15");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import java.io.InputStream;
|
|||
|
||||
import org.jclouds.aws.domain.AWSError;
|
||||
import org.jclouds.aws.filters.FormSignerTest;
|
||||
import org.jclouds.domain.Credentials;
|
||||
import org.jclouds.http.HttpCommand;
|
||||
import org.jclouds.http.HttpRequest;
|
||||
import org.jclouds.http.HttpResponse;
|
||||
|
@ -49,7 +50,7 @@ public class AWSUtilsTest {
|
|||
@BeforeTest
|
||||
protected void setUpInjector() throws IOException {
|
||||
|
||||
utils = FormSignerTest.INJECTOR.getInstance(AWSUtils.class);
|
||||
utils = FormSignerTest.injector(new Credentials("identity", "credential")).getInstance(AWSUtils.class);
|
||||
|
||||
command = createMock(HttpCommand.class);
|
||||
expect(command.getCurrentRequest()).andReturn(createMock(HttpRequest.class)).atLeastOnce();
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.jclouds.aws.xml;
|
||||
|
||||
import static org.testng.Assert.assertEquals;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
import org.jclouds.aws.domain.TemporaryCredentials;
|
||||
import org.jclouds.aws.xml.TemporaryCredentialsHandler;
|
||||
import org.jclouds.date.internal.SimpleDateFormatDateService;
|
||||
import org.jclouds.http.functions.BaseHandlerTest;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
/**
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
// NOTE:without testName, this will not call @Before* and fail w/NPE during surefire
|
||||
@Test(groups = "unit", testName = "TemporaryCredentialsHandlerTest")
|
||||
public class TemporaryCredentialsHandlerTest extends BaseHandlerTest {
|
||||
|
||||
public void test() {
|
||||
InputStream is = getClass().getResourceAsStream("/credentials.xml");
|
||||
|
||||
TemporaryCredentials expected = expected();
|
||||
|
||||
TemporaryCredentialsHandler handler = injector.getInstance(TemporaryCredentialsHandler.class);
|
||||
TemporaryCredentials result = factory.create(handler).parse(is);
|
||||
|
||||
assertEquals(result, expected);
|
||||
assertEquals(result.getAccessKeyId(), expected.getAccessKeyId());
|
||||
assertEquals(result.getSecretAccessKey(), expected.getSecretAccessKey());
|
||||
assertEquals(result.getSessionToken(), expected.getSessionToken());
|
||||
assertEquals(result.getExpiration(), expected.getExpiration());
|
||||
}
|
||||
|
||||
public TemporaryCredentials expected() {
|
||||
return TemporaryCredentials.builder()
|
||||
.accessKeyId("AKIAIOSFODNN7EXAMPLE")
|
||||
.secretAccessKey("wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY")
|
||||
.sessionToken("AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT")
|
||||
.expiration(new SimpleDateFormatDateService().iso8601DateParse("2011-07-11T19:55:29.611Z")).build();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
<Credentials>
|
||||
<SessionToken>AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT</SessionToken>
|
||||
<SecretAccessKey>wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY</SecretAccessKey>
|
||||
<Expiration>2011-07-11T19:55:29.611Z</Expiration>
|
||||
<AccessKeyId>AKIAIOSFODNN7EXAMPLE</AccessKeyId>
|
||||
</Credentials>
|
Loading…
Reference in New Issue