awsdag

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: CC0-1.0 Imports: 15 Imported by: 0

README

awsdag

CI codecov AI Generated

Sign in to AWS IAM Identity Center from a machine with no browser.

aws sso login opens a browser and waits for a redirect back to localhost. Neither exists on the far side of an ssh session, which is how a remote host ends up with a long-lived access key pasted into it instead.

awsdag uses the OAuth 2.0 device authorization grant (RFC 8628), which was designed for this. The remote host prints a URL and a code, you approve them in the browser already open in front of you, and the host polls until it has credentials. Nothing is pasted back.

Installation

Download an archive for your platform from the releases page, or build it yourself with Go 1.27 or later:

go install github.com/winebarrel/awsdag/cmd/awsdag@latest

Usage

Usage: awsdag [flags]

Sign in to AWS IAM Identity Center from a machine with no browser.

Flags:
  -h, --help                   Show context-sensitive help.
      --version
  -p, --profile="default"      Profile to take the Identity Center settings from
                               ($AWS_PROFILE).
  -o, --output="env-export"    Credential format: env-export or json
                               ($AWSDAG_OUTPUT).
$ eval $(awsdag -p dev)
Open the following URL in a browser and confirm the code:

  https://device.sso.us-east-1.amazonaws.com/?user_code=ABCD-EFGH
  ABCD-EFGH

Where to sign in comes from ~/.aws/config. A host that has one has already been told, and passing the start URL on the command line would be answering a question the file has answered. Without -p the profile comes from AWS_PROFILE, then default, as it does everywhere else.

[sso-session my-sso]
sso_start_url = https://d-1234567890.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access

[profile dev]
sso_session = my-sso
sso_account_id = 111122223333
sso_role_name = PowerUserAccess

The file is laid out along the same seam as the flow: the [sso-session] holds the two things signing in needs, and the profile that names it holds the two GetRoleCredentials needs. So a profile like the one above means no questions at all.

Only the file is read: credentials are not resolved and nothing is called, which matters because the sign-in it supplies the settings for has not happened yet. The older layout, where a profile carries sso_start_url and sso_region itself, is read too.

When the profile does not say

A profile with a session and no sso_account_id answers where to sign in and leaves the rest to be asked:

[profile my-sso]
sso_session = my-sso
$ eval $(awsdag -p my-sso)
Open the following URL in a browser and confirm the code:

  https://device.sso.us-east-1.amazonaws.com/?user_code=ABCD-EFGH
  ABCD-EFGH

  1. dev (111122223333)
  2. prod (444455556666)
Account [1-2]: 1
  1. AdministratorAccess
  2. ReadOnlyAccess
Role [1-2]: 2

The choices come from ListAccounts and ListAccountRoles — the same list the AWS access portal shows — so there is no account ID to look up and type. A single account, or a single permission set within one, is taken without asking.

This is worth knowing about even with a fully specified profile: one sign-in covers every account and permission set assigned to you, so a profile naming only the session is enough to reach all of them.

Output

The prompts go to standard error and the credentials to standard output, so eval $(awsdag) reads the credentials without swallowing the instructions you have to act on.

-o env-export, the default, writes assignments to be eval'd:

export AWS_ACCESS_KEY_ID='ASIA...'
export AWS_SECRET_ACCESS_KEY='...'
export AWS_SESSION_TOKEN='...'
export AWS_CREDENTIAL_EXPIRATION='2026-09-08T13:00:00Z'

-o json writes what credential_process reads, so the same command can be wired into ~/.aws/config instead:

{
  "Version": 1,
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "SessionToken": "...",
  "Expiration": "2026-09-08T13:00:00Z"
}

Note that credential_process is invoked by whatever needs credentials, at the moment it needs them, which means the browser approval happens then too.

As a library

The command is a thin wrapper. Auth performs the grant and returns a session, and everything else hangs off it:

session, err := awsdag.Auth(ctx, &awsdag.Options{
	StartURL: "https://d-1234567890.awsapps.com/start",
	Region:   "us-east-1",
})

accounts, err := session.Accounts(ctx)
roles, err := session.Roles(ctx, accounts[0].ID)
creds, err := session.Credentials(ctx, accounts[0].ID, roles[0])

LoadProfile reads the settings out of ~/.aws/config without resolving anything, for a caller that wants the same source the command uses:

profile, err := awsdag.LoadProfile(ctx, "dev")

A session is an access token for the Identity Center instance as a whole, not for any one account. Every account and permission set assigned to you can be exchanged through it, as many times as you like, without going back to a browser — which is also why it is worth rather more than the credentials it hands out.

What it does

  1. RegisterClient registers awsdag as a public client. No credentials are needed: all three of the OIDC operations below are anonymous, which is what makes this work on a host that has none.
  2. StartDeviceAuthorization returns the URL and the code to approve.
  3. CreateToken is polled until someone approves them, backing off when the service asks it to, and giving up when the code expires.
  4. ListAccounts and ListAccountRoles produce the choices, unless a profile already answered them.
  5. GetRoleCredentials exchanges the answer for short-term credentials.

The credentials last as long as the session duration of the permission set — an hour by default, twelve at most. That is a separate clock from the sign-in itself, which lasts eight hours.

Nothing is written to disk. The sign-in is not cached, so every run needs an approval in the browser.

Documentation

Overview

Package awsdag signs in to AWS IAM Identity Center from a machine with no browser.

The sign-in the AWS CLI performs opens a browser and waits for a redirect back to localhost, neither of which exists on the far side of an ssh session. The OAuth 2.0 device authorization grant (RFC 8628) was designed for exactly that situation: this machine prints a URL and a code, a browser somewhere else approves them, and this machine polls until it has a token.

Nothing here needs credentials to start. The three ssooidc operations the grant uses are anonymous, so the only things that must be supplied are the Identity Center start URL and the region it lives in. Everything else -- which accounts the user can reach, which permission sets each one offers -- is discovered from the token afterwards.

Index

Constants

View Source
const ClientName = "awsdag"

ClientName is the name this client registers under. It appears on the approval page in the browser, so it is what someone sees when deciding whether the thing asking for access is the thing they just ran.

Variables

View Source
var ErrNoChoices = errors.New("nothing to choose from")

ErrNoChoices is returned when there is nothing to choose from, which for an account or a permission set means nothing has been assigned.

Formats are the accepted values of Format, in the order they should be offered.

Functions

func Choose

func Choose[T any](in io.Reader, out io.Writer, prompt string, items []T, label func(T) string) (T, error)

Choose asks which of items to use, and returns it.

A single item is taken without asking: there is no choice to make, and a prompt with one answer is just something else to press return on. Anything unreadable is asked again rather than treated as an answer, because the question is which set of credentials to mint and guessing is not an option.

func Notify

func Notify(w io.Writer, auth *Authorization) error

Notify writes the verification URI and user code where someone will see them.

The default destination is standard error rather than standard output, because the credentials go to standard output and `eval $(awsdag ...)` would otherwise swallow the very instructions the caller has to act on.

Types

type Account

type Account struct {
	ID    string
	Name  string
	Email string
}

Account is an AWS account the signed-in user has been assigned.

func (Account) String

func (a Account) String() string

String is what the account looks like in a list of choices.

type Authorization

type Authorization struct {
	// VerificationURI is the page to open; VerificationURIComplete is the
	// same page with the code already filled in.
	VerificationURI         string
	VerificationURIComplete string

	// UserCode is what the page asks for, if it was opened without the code.
	UserCode string

	// ExpiresAt is when the code stops being accepted.
	ExpiresAt time.Time
}

Authorization is what someone has to approve in a browser.

type Credentials

type Credentials struct {
	AccessKeyID     string
	SecretAccessKey string
	SessionToken    string
	Expiration      time.Time
}

Credentials are the short-term STS credentials issued for one permission set in one account.

Their lifetime is the session duration of the permission set -- an hour by default, twelve at most -- and has nothing to do with how long the session that produced them lasts.

func (*Credentials) Write

func (c *Credentials) Write(w io.Writer, format Format) error

Write renders the credentials in the given format.

type Format

type Format string

Format is how credentials are written.

const (
	// EnvExport writes shell assignments meant to be eval'd.
	EnvExport Format = "env-export"

	// JSON writes the object credential_process expects, so that the same
	// command can be wired into ~/.aws/config instead of eval'd.
	JSON Format = "json"
)

type OIDCAPI

OIDCAPI is the part of the ssooidc client the device authorization grant uses. All three operations resolve to anonymous auth, so nothing has to be signed and no credentials have to exist yet -- which is the whole reason this flow works on a machine that has none.

type Options

type Options struct {
	// StartURL is the AWS access portal URL of the Identity Center instance.
	StartURL string

	// Region is where that instance lives. It is not the region the caller
	// intends to work in, and the two are often different.
	Region string

	// Notify presents the verification URI and user code to whoever is going
	// to approve them. Nil prints to standard error, which keeps the prompt
	// clear of the credentials on standard output.
	Notify func(*Authorization) error

	// OIDC and SSO stand in for the service. Nil builds real clients for
	// Region.
	OIDC OIDCAPI
	SSO  SSOAPI

	// Clock and Sleep exist so the polling loop can be tested without
	// waiting. Nil uses the real ones.
	Clock func() time.Time
	Sleep func(time.Duration)
}

Options are the two facts that cannot be discovered, plus the seams tests reach through.

type Profile

type Profile struct {
	StartURL  string
	Region    string
	AccountID string
	Role      string
}

Profile is what a profile in ~/.aws/config has to say about signing in.

The four fields are the four things a run needs, and the file is already laid out along the same seam: an [sso-session] holds the two that authentication needs, and the profile that names it holds the two that GetRoleCredentials needs. Either half may be missing, and a profile with no account or permission set is still worth reading for the other two.

func LoadProfile

func LoadProfile(ctx context.Context, name string, optFns ...func(*config.LoadSharedConfigOptions)) (*Profile, error)

LoadProfile reads a profile out of the shared config.

Only the file is read. Credentials are not resolved and nothing is called, which matters because the sign-in this supplies the settings for is the one that has not happened yet.

type SSOAPI

type SSOAPI interface {
	ListAccounts(context.Context, *sso.ListAccountsInput, ...func(*sso.Options)) (*sso.ListAccountsOutput, error)
	ListAccountRoles(context.Context, *sso.ListAccountRolesInput, ...func(*sso.Options)) (*sso.ListAccountRolesOutput, error)
	GetRoleCredentials(context.Context, *sso.GetRoleCredentialsInput, ...func(*sso.Options)) (*sso.GetRoleCredentialsOutput, error)
}

SSOAPI is the part of the sso client that the access token unlocks. The token carries the caller's identity in a header, so these are anonymous too.

type Session

type Session struct {
	AccessToken string
	ExpiresAt   time.Time
	// contains filtered or unexported fields
}

Session is an approved sign-in: an access token for the Identity Center instance as a whole, not for any one account or permission set. Every account and role assigned to the user can be reached through it until it expires, which makes it worth rather more than the credentials it hands out.

func Auth

func Auth(ctx context.Context, opts *Options) (*Session, error)

Auth runs the device authorization grant to completion and returns the session it produced. It blocks while someone approves the code in a browser, and gives up when the code expires or ctx is cancelled.

func (*Session) Accounts

func (s *Session) Accounts(ctx context.Context) ([]Account, error)

Accounts lists the accounts the session can reach.

This is the same list the AWS access portal shows, and it is bounded by the assignments in Identity Center: an account nobody gave the user is not in it, and asking for credentials there would fail anyway.

func (*Session) ChooseAccount

func (s *Session) ChooseAccount(ctx context.Context, in io.Reader, out io.Writer, known string) (string, error)

ChooseAccount settles which account to use.

A known account is taken as it is. Listing to check that it is assigned would put a call in front of the one that matters and only move the same failure earlier, since GetRoleCredentials refuses an account nobody gave the user anyway.

func (*Session) ChooseRole

func (s *Session) ChooseRole(ctx context.Context, in io.Reader, out io.Writer, accountID, known string) (string, error)

ChooseRole settles which permission set to use in an account.

func (*Session) Credentials

func (s *Session) Credentials(ctx context.Context, accountID, role string) (*Credentials, error)

Credentials exchanges the session for credentials in one account.

A session can do this as many times as it likes, for any account and permission set assigned to the user, without anyone returning to a browser.

func (*Session) Roles

func (s *Session) Roles(ctx context.Context, accountID string) ([]string, error)

Roles lists the permission sets the session can assume in an account.

Directories

Path Synopsis
cmd
awsdag command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL