Skip to content

Commit

Permalink
Add Federated Signin URL Generator
Browse files Browse the repository at this point in the history
Adds a signin "service" in similar fashion to the CLI's configure "service":

* Using the AWS federation endpoint this command takes temporary
  credentials and returns a sign-in URL allowing a user to log in to the
  AWS Management Console using those temporary credentials.

* Follows the published example code for 'Enabling custom identity
  broker access to the AWS console' in the AWS IAM User Guide.

* The name `signin`, while generic and potentially confusing to new
  users, follows as close as possible to the global service naming convention
  similar to `iam.amazonaws.com` (`signin.aws.amazon.com`).

* Resolves #4642 (feature request)
  • Loading branch information
ckabalan committed Aug 27, 2021
1 parent 19f0b7a commit e837d7c
Show file tree
Hide file tree
Showing 10 changed files with 638 additions and 0 deletions.
17 changes: 17 additions & 0 deletions awscli/customizations/signin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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.
from awscli.customizations.signin.signin import SigninCommand


def register_signin_command(cli):
cli.register('building-command-table.main', SigninCommand.add_command)
36 changes: 36 additions & 0 deletions awscli/customizations/signin/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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.


class SigninError(Exception):
""" Base class for all SigninErrors."""
fmt = 'An unspecified error occurred'

def __init__(self, **kwargs):
msg = self.fmt.format(**kwargs)
super(SigninError, self).__init__(msg)
self.kwargs = kwargs


class NonTemporaryCredentialsError(SigninError):
fmt = ("Error: The current profile contains long-term credentials. You may"
" only signin with temporary credentials.")


class SessionDurationOutOfRangeError(SigninError):
fmt = ("Error: The specified Session Duration must be 900 seconds (15"
" minutes) to 43200 seconds (12 hours).")


class FederationResponseError(SigninError):
fmt = "Error: AWS Federation Endpoint: {msg}"
171 changes: 171 additions & 0 deletions awscli/customizations/signin/signin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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.
from awscli.customizations.commands import BasicCommand
from awscli.customizations.signin import exceptions

import urllib.parse
import urllib.request
import urllib.error
from botocore.awsrequest import AWSRequest
from botocore.httpsession import URLLib3Session
import json
import sys


class SigninCommand(BasicCommand):
NAME = 'signin'
DESCRIPTION = BasicCommand.FROM_FILE()
SYNOPSIS = ''
ARG_TABLE = [
{
'name': 'session-duration',
'cli_type_name': 'integer',
'help_text': (
"<p>Specifies the duration of the console session. This is"
" separate from the duration of the temporary credentials that"
" you specify using the DurationSeconds parameter of an"
" sts:AssumeRole call. You can specify a --session-duration"
" maximum value of 43200 (12 hours). If the --session-duration"
" parameter is missing, then the session defaults to the"
" duration of the credentials of the profile used for this "
" command (which defaults to one hour).</p><p>See the"
" documentation for the `sts:AssumeRole` API for details about"
" how to specify a duration using the DurationSeconds"
" parameter. The ability to create a console session that is"
" longer than one hour is intrinsic to the getSigninToken"
" operation of the federation endpoint.</p>"
),
'required': False
},
{
'name': 'destination-url',
'help_text': (
"<p>URL for the desired AWS console page. The browser will"
" automatically redirect to this URL after login.</p><p>To"
" provide this value you will need to set the config option"
" `cli_follow_urlparam` to false.</p>"
),
'required': False
},
{
'name': 'issuer-url',
'help_text': (
"<p>URL for your internal sign-in page. The browser will"
"automatically redirect to this URL after the user's session"
"expires.</p><p>To provide this value you will need to set the"
" config option `cli_follow_urlparam` to false.</p>"
),
'required': False
},
{
'name': 'partition',
'help_text': (
"<p>The AWS partition for the signin URLs.</p><ul>"
"<li>**AWS** = aws.amazon.com</li>"
"<li>**AWS_US_GOV** = amazonaws-us-gov.com</li>"
"<li>**AWS_CN** = amazonaws.cn</li></ul>"
),
'required': False,
'default': 'AWS',
'choices': ['AWS', 'AWS_CN', 'AWS_US_GOV'],
},
]
EXAMPLES = BasicCommand.FROM_FILE()

def __init__(self, session, prompter=None, config_writer=None):
super(SigninCommand, self).__init__(session)

def _run_main(self, parsed_args, parsed_globals):
# Called when invoked with no args "aws signin"
# Reference Architecture: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html

credentials = self._session.get_credentials()
if credentials.token is None:
# Temporary credentials are REQUIRED
raise exceptions.NonTemporaryCredentialsError()

json_credentials = {
'sessionId': credentials.access_key,
'sessionKey': credentials.secret_key,
'sessionToken': credentials.token
}

partitions = {
'AWS': 'aws.amazon.com',
'AWS_US_GOV': 'amazonaws-us-gov.com',
'AWS_CN': 'amazonaws.cn'
}
token_url = self._build_getsignintoken_url(
credentials=json_credentials,
partition=partitions[parsed_args.partition],
session_duration=parsed_args.session_duration
)

# Federation endpoint always returns a JSON object with a
# 'SigninToken' key as long as the request is properly formatted
# with an in-range SessionDuration parameter. Conveniently this
# allows us to test with invalid credentials or partitions we don't
# have credentials for.
req = URLLib3Session()
response = req.send(AWSRequest('GET', token_url).prepare())
if response.status_code >= 400:
raise exceptions.FederationResponseError(
msg=f"HTTP Code {response.status_code}"
)

federation_response = response.content
try:
signin_token_json = json.loads(federation_response)
except ValueError:
raise exceptions.FederationResponseError(
msg='Malformed reponse. Not a JSON string.'
)

if 'SigninToken' not in signin_token_json:
raise exceptions.FederationResponseError(
msg=('Malformed reponse. JSON string does not contain key '
'Signintoken')
)

signin_url = self._build_login_url(
partition=partitions[parsed_args.partition],
signin_token=signin_token_json['SigninToken'],
destination_url=parsed_args.destination_url,
issuer_url=parsed_args.issuer_url
)

sys.stdout.write(signin_url)
return 0

@staticmethod
def _build_getsignintoken_url(credentials, partition,
session_duration=None):
string_credentials = json.dumps(credentials)
url = f"https://signin.{partition}/federation?Action=getSigninToken"
if session_duration:
if not 900 <= session_duration <= 43200:
raise exceptions.SessionDurationOutOfRangeError()
url += f"&SessionDuration={session_duration}"
url += f"&Session={urllib.parse.quote_plus(string_credentials)}"
return url

@staticmethod
def _build_login_url(partition, signin_token, destination_url=None,
issuer_url=None):
url = f"https://signin.{partition}/federation?Action=login"
if issuer_url:
url += f"&Issuer={urllib.parse.quote_plus(issuer_url)}"
dest_url = destination_url or f"https://console.{partition}/"
url += f"&Destination={urllib.parse.quote_plus(dest_url)}"
url += f"&SigninToken={signin_token}"
return url
22 changes: 22 additions & 0 deletions awscli/examples/signin/_description.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Generate a sign-in URL for the AWS Management Console using temporary
credentials.

This command **MUST** be invoked with a profile containing temporary credentials. The profile may not contain long-term credentials including **aws_access_key_id** and **aws_secret_access_key**.

This command is used to provide AWS Management Console access to a set of assumed role credentials. A typical workflow allows for a AWS IAM User without direct console access to assume a role, then run this **signin** command to generate a URL allowing sign-in to the AWS Management Console. Typically this command will be used when an AWS IAM User has an Access Key and Secret Access Key, no console login password, but access to assume a role.

The following credential configuration also allows for transparent role assumption::

[my_user]
aws_access_key_id = AKIAABCDEFGHIJKLMNOP
aws_secret_access_key = ...

[default]
role_arn = arn:aws:iam::012345678910:role/my_role
role_session_name = example-session-name
source_profile = my_user
duration_seconds = 43200

For more information on this process, see `Enabling custom identity broker access to the AWS console`_ in the *AWS Identity and Access Management User Guide*.

.. _`Enabling custom identity broker access to the AWS console`: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html
15 changes: 15 additions & 0 deletions awscli/examples/signin/_examples.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
To generate an AWS Management Console signin URL with the default profile::

$ aws signin

To generate an AWS Management Console signin URL with the my_role profile::

$ aws --profile my_role signin

To go directly to the CloudFormation service page after login::

$ aws signin --destination-url https://console.aws.amazon.com/cloudformation/home

To generate a signin link to AWS GovCloud::

$ aws signin --partition AWS_US_GOV
2 changes: 2 additions & 0 deletions awscli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
from awscli.customizations.sessionmanager import register_ssm_session
from awscli.customizations.sms_voice import register_sms_voice_hide
from awscli.customizations.dynamodb import register_dynamodb_paginator_fix
from awscli.customizations.signin import register_signin_command


def awscli_initialize(event_handlers):
Expand Down Expand Up @@ -183,3 +184,4 @@ def awscli_initialize(event_handlers):
register_ssm_session(event_handlers)
register_sms_voice_hide(event_handlers)
register_dynamodb_paginator_fix(event_handlers)
register_signin_command(event_handlers)
12 changes: 12 additions & 0 deletions tests/functional/signin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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.
72 changes: 72 additions & 0 deletions tests/functional/signin/test_signin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file 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.
from awscli.testutils import FileCreator, BaseAWSCommandParamsTest, \
create_clidriver


class TestSignin(BaseAWSCommandParamsTest):
prefix = 'signin'

def setUp(self):
super(TestSignin, self).setUp()
self.environ['AWS_CONFIG_FILE'] = FileCreator().create_file(
'config',
'[default]\ncli_follow_urlparam = false\n')
self.environ['AWS_ACCESS_KEY_ID'] = 'ASIAAAAAAAAAAAAAAAAAA'
self.environ['AWS_SECRET_ACCESS_KEY'] = 'SECRET_ACCESS_TEST_VALUE'
self.environ['AWS_SESSION_TOKEN'] = 'SESSION_TOKEN_TEST_VALUE'
self.driver = create_clidriver()

def tearDown(self):
super(TestSignin, self).tearDown()

def test_signin(self):
args = (' --partition AWS --destination-url'
' https://console.aws.amazon.com/cloudformation/home'
' --issuer-url http://sso.mycompany.com')
cmdline = self.prefix + args

stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=0)

self.assertIn(
'https://signin.aws.amazon.com/federation',
stdout,
msg='Signin URL does not contain the proper domain and path'
)

self.assertIn(
'Action=login',
stdout,
msg='Signin URL does not contain the proper action parameter'
)

self.assertIn(
'Issuer=http%3A%2F%2Fsso.mycompany.com',
stdout,
msg='Signin URL does not contain the proper issuer parameter'
)

self.assertIn(
('Destination=https%3A%2F%2Fconsole.aws.amazon.com%2Fcloudformatio'
'n%2Fhome'),
stdout,
msg='Signin URL does not contain the proper destination parameter'
)

self.assertIn(
('SigninToken='),
stdout,
msg='Signin URL does not contain a signin token'
)

self.assertEqual(rc, 0, msg='Signin does not return a 0 exit code')
Loading

0 comments on commit e837d7c

Please sign in to comment.