tech.guitarrapc.cóm

Technical updates

AWS SSOで取得した一時認証をC#で利用する

IAM Userを利用してC#からAWSのリソースにアクセスする場合、AccessKeyやAccessSecretKeyを直接利用して認証します。AWS IAM Identity Center(旧AWS SSO)でも同様にAccessKeyやAccessSecretKeyを取得して利用できますが、SSOログインした一時認証情報を取得して利用できます。今回はそのメモです。

なぜAWS SSOで取得した一時認証を利用するのか

IAM UserのAccessKeyやAccessSecretKeyの問題点として、発行したキーに期限がないことはよく指摘されます。キー利用時の本人確認にMFAを強制したくともアクセスキー利用時は強制できません1。AWSコンソールのログイン可否もユーザーごとに設定する手間もあります。

AWS IAM Identity Centerの場合、aws sso loginなどのSSOログインで取得した一時認証情報に期限があります。またaws sso loginを行うのにAWS AccessKey/AccessSecretKeyが不要なのもうれしいポイントでしょう。何かしらの事情でAccessKey/AccessSecretKeyを使いたい場合でも、AWSアクセスポータルで自動発行される上、期限が設定されているのも良い点です。ただ、AccessKey/AccessSecretKeyを使うとき.aws/credentialsや環境変数に埋める必要があるのは煩雑さを否めないため、aws sso loginでログインした認証を使うと使い勝手が良いです。

SSO認証を利用する側、管理する側両方にとって以下がメリットと言えます。

  • 認証に期限がある
  • ログインポータルが管理不要で提供される
  • MFAがログイン、aws sso login時に強制される
  • AccessKey/AccessSecretKeyが自動発行される
  • AccessKey/AccessSecretKeyを埋め込む必要がない

逆に面倒なポイントも裏返しとなります。C#アプリケーションでAWSのリソースにアクセスする時もパッとみるとどうすればいいか悩みやすいです。どうすればいいのか見てみましょう。

  • 時限が切れると再認証が必要
  • SSOに対応していないツールや用途で、AccessKey/AccessSecretKeyを直接使うのが手間2

AWS SDK for .NETでSSO認証を利用する

AWS SSO認証を利用する場合、2つの認証方法を選ぶことができます。

  1. SSOログインをC#アプリケーション内部から行う
  2. AWS CLIでSSOログインを行い、C#アプリケーションは一時認証情報を利用する

SSO認証を見ていく前に、基本のおさらいです。IAM Userの場合、次のようにAccessKey/AccessSecretKeyを直接指定して認証できます。パッケージを追加して、次のC#コードを実行するとS3バケット一覧を取得できます。

dotnet add package AWSSDK.Core
dotnet add package AWSSDK.S3
using Amazon;
using Amazon.Runtime;
using Amazon.Runtime.CredentialManagement;
using Amazon.S3;

var credentials = new BasicAWSCredentials("ACCESSKEY", "ACCESS_SECRETKEY");
var s3client = new AmazonS3Client(credentials, RegionEndpoint.APNortheast1);
var buckets = await s3client.ListBucketsAsync();
buckets.Dump();

SSOログインをC#アプリケーション内部から行う

C#アプリケーションの実行時に認証がなければ外部ブラウザを開いてSSOログインを行い、認証情報を取得する方法です。AWS SDK for .NETのSSOAWSCredentialsを利用します。

NuGetパッケージは、IAM Userの時と違いAWSSDK.SSOとAWSSDK.SSOIDCの2パッケージが必要なことに注意してください。特にAWSSDK.SSOOIDCはなくてもコンパイルできるのですが、パッケージ追加せず実行すると無応答になるので必ず含めましょう。

dotnet add package AWSSDK.Core
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.SSO
dotnet add package AWSSDK.SSOOIDC

次のようなC#コードを用意すると、指定したプロファイルでSSOログインからS3バケット一覧取得まで行います。

using System;
using System.Diagnostics;
using Amazon;
using Amazon.Runtime;
using Amazon.Runtime.CredentialManagement;
using Amazon.S3;

var profile = "YOUR_PROFILE_NAME";
var credentials = LoadSsoCredentials(profile);

// any operation you want to do with sso credentials.
var s3client = new AmazonS3Client(credentials, RegionEndpoint.APNortheast1);
var buckets = await s3client.ListBucketsAsync();
foreach (var bucket in buckets.Buckets)
{
    Console.WriteLine(bucket.BucketName);
}

static AWSCredentials LoadSsoCredentials(string profileName)
{
    var chain = new CredentialProfileStoreChain();
    if (!chain.TryGetAWSCredentials(profileName, out var credentials))
        throw new Exception($"Failed to find the {profileName} profile");
    if (credentials is not SSOAWSCredentials ssoCredentials)
        throw new Exception($"Credential found but it was not {nameof(SSOAWSCredentials)}");

    ssoCredentials.Options.ClientName = "LinqPad";
    ssoCredentials.Options.SsoVerificationCallback = args =>
    {
        // Launch a browser window that prompts the SSO user to complete an SSO sign-in.
        // This method is only invoked if the session doesn't already have a valid SSO token.
        // NOTE: Process.Start might not support launching a browser on macOS or Linux. If not,
        //       use an appropriate mechanism on those systems instead.
        Process.Start(new ProcessStartInfo
        {
            FileName = args.VerificationUriComplete,
            UseShellExecute = true
        });
    };

    return ssoCredentials;
}

実行してみましょう。

ブラウザでデバイス認証のためコード一致を確認される

LinqPadから実行したのでその許可を求める表示になる

LinqPadにアクセス許可を与えた状態

ブラウザでSSO認証をしてアプリケーションに戻ってくるとバケットを取得できていることがわかります。

foobar-bucket
bazpiyo-bucket

AWS SSO認証からやってくれるのは割と便利な一方で、アプリケーションに組み込むのは何かとちょっと使いにくい部分もあります。全員が同じ環境で同じやり方を提供するなら良いですが、個人の環境でやる場合はSSOログインをCLIで行うほうが楽なこともあります。

AWS CLIでSSOログインを行い、C#アプリケーションは一時認証情報を利用する

AWS CLIでSSOログインを行うと、$HOME/.aws/sso/cache/xxxxx.jsonに一時認証情報が書き込まれます。この一時認証をアプリケーションで再利用する方法です。

事前にAWS CLIでSSOログインを行っておきます。

$ aws sso login --profile YOUR_PROFILE_NAME

NuGetパッケージはC#アプリケーションでSSOログインするときと同じパッケージです。こちらも同様に、AWSSDK.SSOOIDCを必ず含めましょう。

dotnet add package AWSSDK.Core
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.SSO
dotnet add package AWSSDK.SSOOIDC

次のようなC#コードを用意すると、指定したプロファイルのSSO認証キャッシュを使ってAWSCredentials認証を取得、S3バケット一覧取得します。

using System;
using Amazon;
using Amazon.Runtime;
using Amazon.Runtime.CredentialManagement;
using Amazon.S3;

var profile = "YOUR_PROFILE_NAME";
var credentials = LoadSsoCredentials(profile);

// any operation you want to do with sso credentials.
var s3client = new AmazonS3Client(credentials, RegionEndpoint.APNortheast1);
var buckets = await s3client.ListBucketsAsync();
foreach (var bucket in buckets.Buckets)
{
    Console.WriteLine(bucket.BucketName);
}

static AWSCredentials LoadSsoCredentials(string profileName)
{
    var chain = new CredentialProfileStoreChain();
    if (!chain.TryGetAWSCredentials(profileName, out var credentials))
        throw new Exception($"Failed to find the {profileName} profile");

    return credentials;
}

事前にログインしておいた認証が生きている限りはサクっと実行されます。

foobar-bucket
bazpiyo-bucket

本当は書きたかったこと

AWS SSOを利用できるようになったけどまだAWS SDK.NETでサポートされてころに書いたコードを紹介しようとして、今見てみたら公式サポートが入ってて無に帰しました。供養代わりにおいておきます。

以下のパッケージが必要です。このやり方だとAWSSDK.SSOIDCは不要です。

dotnet add package AWSSDK.Core
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.SSO

やっていることは、AWS SSOの認証キャッシュを拾ってきてAWS認証に差し替えているだけです。AWS SSOの設定は.aws/configに書くのですが実は2つ記載方法があり両方に対応しています。sso sesesionセクションを使わない旧方式だとキャッシュのjsonファイル名はstart-urlのSHA1ハッシュだったんですが、ssso sessionセクションを利用する新方式ではsession-nameのSHA1ハッシュになります。

var profile = "YOUR_PROFILE_NAME";
var credentials = await GetSSOProfileCredentials(profile);

// any operation you want to do with sso credentials.
var s3client = new AmazonS3Client(credentials, RegionEndpoint.APNortheast1);
var buckets = await s3client.ListBucketsAsync();
foreach (var bucket in buckets.Buckets)
{
    Console.WriteLine(bucket.BucketName);
}

/// <summary>
/// Get AccessKey from AWS SSO cached file.
/// </summary>
static async Task<AWSCredentials> GetSSOProfileCredentials(string profileName)
{
    // ${HOME}/.aws/config
    var configFilePath = Path.Combine(SharedCredentialsFile.DefaultDirectory, "config");
    // ${HOME}/.aws/sso/cache
    var ssoFolderPath = Path.Combine(SharedCredentialsFile.DefaultDirectory, "sso", "cache");
    var (accountId, roleName, ssoCacheSeed) = GetSsoProfileValues(configFilePath, profileName);

    var cacheFileName = GetSha1(ssoCacheSeed) + ".json";
    var fullCacheFilePath = Path.Combine(ssoFolderPath, cacheFileName);
    if (!File.Exists(fullCacheFilePath))
        throw new FileNotFoundException($"aws sso cache file {fullCacheFilePath} not found, please confirm you have already logged in with 'aws sso login --profile {profileName}'");
    var cacheObject = JsonSerializer.Deserialize<AwsSsoCacheObject>(File.ReadAllText(fullCacheFilePath));

    if (cacheObject is null)
        throw new ArgumentNullException(nameof(cacheObject));
    if (cacheObject.ExpiresAt < DateTime.UtcNow)
        throw new InvalidDataException("Obtained expiresAt is past date.");

    using var ssoClient = new AmazonSSOClient(
        new AnonymousAWSCredentials(),
        new AmazonSSOConfig { RegionEndpoint = RegionEndpoint.GetBySystemName(cacheObject.Region) });
    var getRoleCredentialsResponse = await ssoClient.GetRoleCredentialsAsync(new GetRoleCredentialsRequest
    {
        AccessToken = cacheObject.AccessToken,
        AccountId = accountId,
        RoleName = roleName
    });

    var sessionCredential = new SessionAWSCredentials(
        getRoleCredentialsResponse.RoleCredentials.AccessKeyId,
        getRoleCredentialsResponse.RoleCredentials.SecretAccessKey,
        getRoleCredentialsResponse.RoleCredentials.SessionToken);

    // You can omit on local run. For Role credential fallback. Fargate/Lambda/EC2 Instance.
    FallbackCredentialsFactory.CredentialsGenerators.Insert(0, () => sessionCredential);

    return sessionCredential;

    static string GetSha1(string value)
    {
        using var sha = SHA1.Create();
        var hashSpan = sha.ComputeHash(Encoding.UTF8.GetBytes(value)).AsSpan();
        return Convert.ToHexString(hashSpan).ToLowerInvariant();
    }

    static (string AccountId, string RoleName, string SsoCacheSeed) GetSsoProfileValues(string configFilePath, string profileName)
    {
        var profileFile = new ProfileIniFile(configFilePath, false);
        if (!profileFile.TryGetSection(profileName, false, false, out var profileProperties, out _))
            throw new ArgumentNullException($"profile '{profileProperties}' not found in {configFilePath}.");

        var accountId = profileProperties["sso_account_id"];
        var roleName = profileProperties["sso_role_name"];

        // Get SSO cache file seed.
        // * legacy format use sso_start_url to generate sso cache file
        // * new format use sso_session name to generate sso cache file (see: https://github.com/aws/aws-sdk-go-v2/blob/24f7e3aaaf4a6e94d2fc05a7ef0fac4bb80b7e01/config/resolve_credentials.go#L211)
        if (!profileProperties.TryGetValue("sso_start_url", out var ssoCacheSeed))
        {
            // both legacy and new format is missing
            if (!profileProperties.TryGetValue("sso_session", out var ssoSesssion))
                throw new ArgumentNullException($"Both sso_start_url and sso_session missing in profile {profileName}");

            if (profileFile.TryGetSection(ssoSesssion, true, false, out var ssoSessionProperties, out _))
            {
                ssoCacheSeed = ssoSesssion; // new format
            }
        }

        return (accountId, roleName, ssoCacheSeed);
    }
}

internal class AwsSsoCacheObject
{
    [JsonPropertyName("startUrl")]
    public required string StartUrl { get; init; }
    [JsonPropertyName("region")]
    public required string Region { get; init; }
    [JsonPropertyName("accessToken")]
    public required string AccessToken { get; init; }
    [JsonPropertyName("expiresAt")]
    public required DateTime ExpiresAt { get; init; }
}

まとめ

AWS認証の中心はAWS IAM Identity Centerにシフトしつつあり、たとえSSOを使っていなくてもこれを使うのがベストプラクティスとなっていくでしょう。アプリケーションエンジニアの視点からも自分の認証方法がaws sso loginをベースにできるのはAzureやGCPなど他クラウドと体験が近く使いやすいものです。

ローカルからAWSリソースアクセスしたいんだけど、という時でもSDKが面倒をみてくれるようになっているので、ぜひ活用してください。

参考

Gistにもおいておきます


  1. Assume Roleと併用するなどの追加設定をしない
  2. コンテナで利用したり一部CLIなど