0
2

AWSのCognitoユーザーが自身専用のS3のディレクトリのコンテンツを編集可能なSwiftUI検証アプリを作ってみた

Posted at

はじめに

AWSUser PoolUser IdentityAWS-SDK-Swift利用してS3 BucketのユーザーのHome Directory内のコンテンツを編集する動作検証アプリを試してみたいと思います。Home Directoryと言っているのは、ログインユーザーのみがRead/Writeなどの権限を持っていていて、他のログインユーザーはアクセスできない環境を目指しています。よって仮にアプリがハックされて他のユーザーのデータにアクセスを試みても、AWSレベルで保護される事を目的とします。

前回同様に、将来の自分への備忘録を目的としていますので、部分的に記載漏れなどがあるかもしれませんが、同様な事をやろうと思った人が、一部でも参考になるなら幸いです。また、執筆時点でのAWS-SDK-Swiftのステータスはデベロッパープレビューなので、APIなどに変更があるかもしれません。

そして、AWSへのrootレベルもしくは必要な権限は必要です。ちなみに、私自身AWSはほぼ初心者なので、間違ったもしくは理解不足による不正確な記載がなされているばあいもあるので、あらかじめご了承ください。

いつもは、動作検証系のプロジェクトにHelloとかつけてやっているのですが、ここに至るまでもいろいろな作りかけができてしまって、管理が難しくなってきたので、これからは宝石の名前を足跡にしようと、今回はAmberProject Prefixとして採用しました。

User pools

Name Property
User pool name amber-user-pool
User pool ID ap-northeast-1_7R43qBEk1
ARN arn:aws:cognito-idp:ap-northeast-1:566372147352:userpool/ap-northeast-1_7R43qBEk1
Advanced security Disabled
Cognito user pool sign-in options Email
Federated identity provider sign-in none
Multi-factor authentication No MFA
Self-service account recovery Enabled
Recovery message delivery method Email only
Allow Cognito to automatically send messages to verify and confirm Yes
Attributes to verify Send email message, verify email address
Keep original attribute value active when an update is pending Enabled
Active attribute values when an update is pending Email address
Required attributes email
Custom attributes none
Self-registration Enabled
Cognito domain https://amber-user-pool.auth.ap-northeast-1.amazoncognito.com

Identity Pool

Name Property
Identity pool name amber-identity-pool
Identity pool ID ap-northeast-1:41d41301-fcdf-4ef6-8a6e-de24167b8dc5
Authenticated role service-role/amber-user-pool-role
Authenticated role ARN arn:aws:iam::566372147352:role/service-role/amber-user-pool-role
Identity provider type Amazon Cognito user pool
Identity provider ap-northeast-1_7R43qBEk1
Attributes for access control Inactive
Basic authentication Inactive

App Integration

Name Property
App client name amber-user-pool-app-client
Client ID 2m2u617er4kjig9ar9k0qhnaaq
Client secret -
Authentication flows ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_PASSWORD_AUTH, ALLOW_USER_SRP_AUTH
Hosted UI status Available
Allowed callback URLs amber-user-pool-app://auth/callback/signin
Allowed sign-out URLs amber-user-pool-app://auth/callback/signout
Identity providers Cognito user pool directory
OAuth grant types Authorization code grant
OpenID Connect scopes aws.cognito.signin.user.admin, emailm openid

S3 Bucket

Name Property
Bucket name amber-user-pool-bucket
AWS Region Asia Pacific (Tokyo) ap-northeast-1
Amazon Resource Name (ARN) arn:aws:s3:::amber-user-pool-bucket

Bucket Policy JSON

bucket-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::566372147352:role/service-role/amber-user-pool-role"
            },
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::amber-user-pool-bucket/home/${cognito-identity.amazonaws.com:sub}/*",
                "arn:aws:s3:::amber-user-pool-bucket"
            ]
        }
    ]
}

IAM > Role

Name Property
Role name amber-user-pool-role
ARN arn:aws:iam::566372147352:role/service-role/amber-user-pool-role
Trusted relationships.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "cognito-identity.amazonaws.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "cognito-identity.amazonaws.com:aud": "ap-northeast-1:41d41301-fcdf-4ef6-8a6e-de24167b8dc5"
                },
                "ForAnyValue:StringLike": {
                    "cognito-identity.amazonaws.com:amr": "authenticated"
                }
            }
        }
    ]
}
amber-user-pool-role-policy.json
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "AllowListingOfUserFolder",
			"Effect": "Allow",
			"Action": "s3:ListBucket",
			"Resource": "arn:aws:s3:::amber-user-pool-bucket"
		},
		{
			"Sid": "AllowUserFolderOperations",
			"Effect": "Allow",
			"Action": [
				"s3:GetObject",
				"s3:PutObject",
				"s3:DeleteObject"
			],
			"Resource": "arn:aws:s3:::amber-user-pool-bucket/home/${cognito-identity.amazonaws.com:sub}/*"
		}
	]
}

App

クライアントアプリを実装します。今回は毎年期待しながら、食わず嫌いなSwiftUIで実装していきたいと思います。アプリ名は「Amber User Pool」にしましたSDKはAWS-SDK_Swiftを利用します。ステータスはいまだにDeveloper Previewなので留意しましょう。執筆時点でのバージョンは「0.47.0」です。プロジェクトに以下のパッケージを追加します。今回はMacアプリを想定してみます。

https://github.com/awslabs/aws-sdk-swift

パッケージの中から今回組み込むモジュールは以下の通りです。

AWSClientRuntime
AWSCognitoldentity
AWSCognitoldentityProvider
AWSS3

検証プロジェクトをGitHubに置いておきます。念を押しておきますが、メインはCognitoベースの権限確認の為、アプリとしての完成度は不十分です。現時点でのコードはトークンを保存するわけではなく、起動毎に毎度サインインする必要があります。また、ユーザー体験や挙動に問題がありますが、あくまでもメインは権限の動作確認である事をご理解ください。さらに、各種ID類は各々自分のID等に置き換えてください。

簡単に検証アプリを紹介しておきます。起動すると、サインインを求められます。User Poolに登録した。Userでロウインします。

ログインするとユーザーのホームディレクトリにコンテンツの一覧が表示され、選択するとテキストの編集画面がディテールビューに表示されます。編集後「Save」ボタンで保存、または「Revert」で編集破棄されます。「+」ボタンで新規追加、リロードボタンで一覧を更新します。別デバイスでコンテンツの追加削除した場合はこれで更新されます。「Signout」でサインアプトします。

検証用として、デバック用のボタンがあり、わかりにくいですが、「private/README.txt」とありますが、ここにs3のキーを入力するとそのオブジェクト(ファイル)にアクセスできます。検証用に、「private/README.txt」にテストデータを置いてみましたが、「Access Test」で「Access Dened」が表示されます。コンテンツが読み込めた場合は、下部にその内容が表示されます。読み込めた場合は、Policyに問題があると考えられます。

といった感じで、劣化版「Notes」アプリといった感じですが、権限検証目的としては十分です。ユーザーはHosted UIからサインアップさせるか、コンソールから作成するか、事前に用意する必要があります。

ServiceManager

AWSアクセス関連をマネージャとしてシングルトンにしました。今から思うと設計はイマイチですが、何しろAWSのCognitoの実装と挙動が謎すぎて、試行錯誤で迷走していたので、ご容赦ください。

ServiceManager.swift
import Foundation
import AWSClientRuntime
import AWSSDKIdentity
import AWSCognitoIdentity
import AWSCognitoIdentityProvider
import Smithy
import SmithyIdentity
import AWSS3
import CryptoKit
import SmithyHTTPAPI


extension String: Error {
}

class ServiceManager {

	typealias MD5 = Data
	struct Token {
		let identityId: String
		let accessToken: String
		let idToken: String
		let refreshToken: String
		let expires: Date
		let sub: String
		let email: String
		init?(identityId: String, accessToken: String?, idToken: String?, refreshToken: String?) {
			guard
				let accessToken = accessToken,
				let idToken = idToken,
				let refreshToken = refreshToken
			else { return nil }
			self.identityId = identityId
			self.accessToken = accessToken
			self.idToken = idToken
			self.refreshToken = refreshToken

			guard let jwt = try? Self.decodeJWT(token: idToken)
			else { return nil }
			
			guard
				let sub = jwt["sub"] as? String,
				let email = jwt["email"] as? String,
				let exp = jwt["exp"] as? Int
			else { return nil }
			self.sub = sub
			self.email = email
			self.expires = Date(timeIntervalSince1970: TimeInterval(exp))
		}
		static func decodeJWT(token: String) throws -> [String: Any] {
			enum JWTError: Error {
				case invalidToken
				case decodingFailed
			}
			let segments = token.split(separator: ".")
			guard segments.count == 3 else {
				throw JWTError.invalidToken
			}
			func decodeBase64Url(_ string: String) -> Data? {
				var base64 = string
					.replacingOccurrences(of: "-", with: "+")
					.replacingOccurrences(of: "_", with: "/")
				let paddingLength = 4 - base64.count % 4
				if paddingLength < 4 {
					base64.append(contentsOf: repeatElement("=", count: paddingLength))
				}
				return Data(base64Encoded: base64)
			}
			guard let payloadData = decodeBase64Url(String(segments[1]))
			else { throw JWTError.decodingFailed }
			guard let json = try? JSONSerialization.jsonObject(with: payloadData, options: []), let payload = json as? [String: Any]
			else { throw JWTError.decodingFailed }
			return payload
		}
		var hasExpired: Bool {
			return Date() >= self.expires
		}
	}
	struct Credentials {
		let accessKeyId: String
		let secretKey: String
		let sessionToken: String
		let expiration: Date
		init?(credentials: CognitoIdentityClientTypes.Credentials?) {
			guard
				let credentials = credentials,
				let accessKeyId = credentials.accessKeyId,
				let secretKey = credentials.secretKey,
				let sessionToken = credentials.sessionToken,
				let expiration = credentials.expiration
			else { return nil }
			self.accessKeyId = accessKeyId
			self.secretKey = secretKey
			self.sessionToken = sessionToken
			self.expiration = expiration
		}
	}
	class Session: Hashable {
		init(token: Token, serviceManager: ServiceManager) {
			self.token = token
			self.serviceManager = serviceManager
		}
		deinit {
		}
		private var _s3client: S3Client?
		var s3client: S3Client {
			get async throws {
				if let s3client = self._s3client {
					if self.token.hasExpired {
						guard let token = try await serviceManager.refreshToken(self.token)
						else { throw "failed refresh token" }
						self.token = token
					}
					else {
						return s3client
					}
				}
				let s3client = try await self.serviceManager.makeS3Client(from: self.token)
				self._s3client = s3client
				return s3client
			}
		}
		unowned let serviceManager: ServiceManager
		var email: String { return self.token.email }
		var sub: String { return self.token.sub }
		var token: Token
		func hash(into hasher: inout Hasher) {
			hasher.combine(email)
		}
		static func == (lhs: Session, rhs: Session) -> Bool {
			return lhs.email == rhs.email
		}
	}
	class Object: Hashable, Identifiable {
		let key: String
		let hash: Data
		let lastModified: Date
		init?(_ object: S3ClientTypes.Object) {
			guard
				let key = object.key, (key as NSString).pathExtension == TextContentView.pathExtension,
				let eTag = object.eTag,
				let hash = eTag.trimmingQuoteCharactors().hexadecimalData,
				let lastModified = object.lastModified
			else { return nil }
			self.key = key
			self.hash = hash
			self.lastModified = lastModified
		}
		var filename: String {
			return (self.key as NSString).lastPathComponent
		}
		var id: String {
			return self.key
		}
		func hash(into hasher: inout Hasher) {
			hasher.combine(self.key)
		}
		static func == (lhs: Object, rhs: Object) -> Bool {
			return lhs === rhs
		}
	}

	static let shared = ServiceManager()
	private init() {
	}

	func signin(email: String, password: String) async throws -> Session {
		let authParameters: [String: String] = [
			"USERNAME": email,
			"PASSWORD": password
		]
		let identityProviderClient = try CognitoIdentityProviderClient(region: Self.region)
		guard let result = try await identityProviderClient.initiateAuth(input: InitiateAuthInput(authFlow: .userPasswordAuth, authParameters: authParameters, clientId: Self.appClientID)).authenticationResult
		else { throw "Failed to get token" }
		guard let idToken = result.idToken, let identityId = try await self.getIdentityId(with: idToken)
		else { throw "Failed to get identity ID" }
		
		guard let token = Token(identityId: identityId, accessToken: result.accessToken, idToken: result.idToken, refreshToken: result.refreshToken)
		else { throw "Failed to get token" }
		print("Signin Token: \(token)") // Log the token
		let credentials = try await self.getAWSCredentials(with: token.idToken)
		print("Signin Credentials: \(String(describing: credentials))") // Log the credentials
		return Session(token: token, serviceManager: self)
	}
	func makeS3Client(from token: Token) async throws -> S3Client {
		let cognitoIdentityClient = try CognitoIdentityClient(region: Self.region)
		let logins: [String: String] = [
			"cognito-idp.\(Self.region).amazonaws.com/\(Self.userPoolID)": token.idToken
		]
		let getIdResponse = try await cognitoIdentityClient.getId(input: GetIdInput(identityPoolId: Self.identityPoolID, logins: logins))
		guard let identityId = getIdResponse.identityId
		else { throw "Failed to get identity ID." }

		let output2 = try await cognitoIdentityClient.getCredentialsForIdentity(input: GetCredentialsForIdentityInput(identityId: identityId, logins: logins))
		guard let credentials = Credentials(credentials: output2.credentials)
		else { throw "Failed to get credentials" }

		let identity = AWSCredentialIdentity(accessKey: credentials.accessKeyId, secret: credentials.secretKey, sessionToken: credentials.sessionToken)
		let resolver = try StaticAWSCredentialIdentityResolver(identity)
		let configuration = try await S3Client.S3ClientConfiguration(awsCredentialIdentityResolver: resolver, region: Self.region)
		let s3Client = S3Client(config: configuration)
		return s3Client
	}
	private func refreshToken(_ token: Token) async throws -> Token? {
		let client = try CognitoIdentityProviderClient(region: Self.region)
		let authParameters = [
			"REFRESH_TOKEN": token.refreshToken
		]
		guard let result = try await client.initiateAuth(input: InitiateAuthInput(authFlow: .refreshToken, authParameters: authParameters, clientId: Self.appClientID)).authenticationResult
		else { return nil }
		guard let newToken = Token(identityId: token.identityId, accessToken: result.accessToken, idToken: result.idToken, refreshToken: result.refreshToken ?? token.refreshToken)
		else { return nil }
		return newToken
	}
	func listTextContents(session: Session) async throws -> [Object] {
		let prefix = "home/\(session.token.identityId)/"
		print(self.self, #function, "prefix=", prefix)
		let contents = try await session.s3client.listObjectsV2(input: ListObjectsV2Input(bucket: Self.bucket, delimiter: "/", prefix: prefix)).contents ?? []
		return contents.compactMap { Object($0) }
	}
	private func randomText() -> String {
		return [
			"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
			"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
			"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
			"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
		].randomElement()!
	}
	func addTextContent(session: Session) async throws -> Object? {
		let key = "home/\(session.token.identityId)/\(UUID().uuidString).\(TextContent.pathExtension)"
		let textContent = TextContent(key: key, text: self.randomText())
		try await self.saveTextContent(session: session, textContent: textContent)
		
		let object = try await session.s3client.listObjectsV2(input: ListObjectsV2Input(bucket: Self.bucket, delimiter: "/", prefix: key)).contents?.first
		return object.flatMap { Object($0) }
	}
	func saveTextContent(session: Session, textContent: TextContent) async throws {
		_ = try await session.s3client.putObject(input: PutObjectInput(body: ByteStream.data(textContent.data), bucket: Self.bucket, key: textContent.key))
	}
	func loadTextContent(session: Session, key: String) async throws -> TextContent? {
		let output = try await session.s3client.getObject(input: GetObjectInput(bucket: Self.bucket, key: key, responseCacheControl: "max-age=2"))
		guard let data = try await output.body?.readData()
		else { return nil }
		let text = String(data: data, encoding: .utf8) ?? ""
		let textContent = TextContent(key: key, text: text)
		return textContent
	}
	func deleteObject(session: Session, key: String) async throws {
		_ = try await session.s3client.deleteObject(input: DeleteObjectInput(bucket: Self.bucket, key: key))
	}
	func getObject(session: Session, s3Key: String) async throws -> String? {
		do {
			let result = try await session.s3client.getObject(input: GetObjectInput(bucket: Self.bucket, key: s3Key, responseCacheControl: "max-age=2"))
			if let data = try await result.body?.readData(),
				let string = String(data: data, encoding: .utf8) {
				return string
			}
			else {
				return nil
			}
		}
		catch {
			switch error {
			case let error as AWSServiceError:
				throw error.errorCode ?? "\(error)"
			default:
				throw error
			}
		}
	}
	
	private func exchangeCodeForTokens(code: String) async throws -> Token {
		// URL for the Cognito token endpoint
		
		// Set up the request
		var request = URLRequest(url: URL(string: Self.tokenURL)!)
		request.httpMethod = "POST"
		request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
				
		// Body parameters
		let params = [
			"grant_type": "authorization_code",
			"client_id": Self.appClientID,
			"code": code,
			"redirect_uri": Self.signinRedirectURL
		]
		request.httpBody = params.map { "\($0.key)=\($0.value)" }.joined(separator: "&").data(using: .utf8)
		
		// Perform the network request
		let (data, response) = try await URLSession.shared.data(for: request)
		
		// Check for response status code
		guard let httpResponse = response as? HTTPURLResponse
		else { throw "Failed to exchange code for tokens: invalid http response" }
		guard httpResponse.statusCode == 200
		else { throw "Failed to exchange code for tokens: Invalid response: code=\(httpResponse.statusCode)" }

		// Parse the response
		if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
			if	let id_token = json["id_token"] as? String,
				let access_token = json["access_token"] as? String,
				let refresh_token = json["refresh_token"] as? String,
				let identityId = try await self.getIdentityId(with: id_token),
				let token = Token(identityId: identityId, accessToken: access_token, idToken: id_token, refreshToken: refresh_token) {
				return token
			}
			else {
				throw NSError(domain: "\(Self.self)", code: 101, userInfo: [
					NSLocalizedDescriptionKey: "insufficient token",
					"json": json
				])
			}
		}
		else {
			throw "\(Self.self) \(#function): response for the code is not a json."
		}
	}
	private func getIdentityId(with idToken: String) async throws -> String? {
		let configuration = try await CognitoIdentityClient.CognitoIdentityClientConfiguration(region: Self.region)
		let identityClient = CognitoIdentityClient(config: configuration)
		let logins: [String: String] = [
			"cognito-idp.\(Self.region).amazonaws.com/\(Self.userPoolID)": idToken
		]
		let request = GetIdInput(identityPoolId: Self.identityPoolID, logins: logins)
		let response = try await identityClient.getId(input: request)
		return response.identityId
	}
	func getAWSCredentials(with idToken: String) async throws -> Credentials? {
		let identityId = try await self.getIdentityId(with: idToken)
		assert(identityId != nil)
		let configuration = try await CognitoIdentityClient.CognitoIdentityClientConfiguration(region: Self.region)
		let identityClient = CognitoIdentityClient(config: configuration)
		let logins:  [String: String] = [
			"cognito-idp.\(Self.region).amazonaws.com/\(Self.userPoolID)": idToken
		]
		let request = GetCredentialsForIdentityInput(customRoleArn: nil, identityId: identityId, logins: logins)
		let response = try await identityClient.getCredentialsForIdentity(input: request)
		return response.credentials.flatMap { Credentials(credentials: $0) }
	}
	func handleCognitoRedirect(url: URL) async throws -> Session? {
		let components: URLComponents? = URLComponents(url: url, resolvingAgainstBaseURL: true)
		if components?.scheme?.lowercased() == Self.customScheme.lowercased() {
			if components?.path.lowercased() == "/callback/signin" {
				if let code = components?.queryItems?.first(where: { $0.name == "code" })?.value {
					let token = try await self.exchangeCodeForTokens(code: code)
					print("Callback Token: \(token)") // Log the token
					let credentials = try await self.getAWSCredentials(with: token.idToken)
					print("Callback Credentials: \(String(describing: credentials))") // Log the credentials
					return Session(token: token, serviceManager: self)
				}
			}
		}
		return nil
	}
	func signout(session: Session) async throws {
		let client = try CognitoIdentityProviderClient(region: Self.region)
		_ = try await client.globalSignOut(input: GlobalSignOutInput(accessToken: session.token.accessToken))
	}
}

はまりポイント

今回の最大のはまりポイントは${cognito-identity.amazonaws.com:sub}JWTCognitosubすなわちUser IDと思い込んで、「Access Denied」の壁がなかなか突破できなくて、Chat GPTもClaudeの嘘に気が付かなくてかなりの時間を無駄にしました。一度、「Identity ID」じゃないかと思った事もあったのですが、ChatGPTに違うと言われたり、試してみたけど「Access Denied」でおそらく別の原因でアクセス拒否されたのですが、どのポリシーに引っかかってダメなのかと、ログもとれないので、長期間にわたって、あっちさわってこっち
さわってと、「ルービックキューブを適当に回して揃うのを待つ」みたいな感じで結構辛かったです。いやAmazonさんこの「:sub」ってネーミング絶対勘違いします。

まとめ

AWSのCognitoのUser PoolとIdentity Poolでユーザー固有のHome directoryのみアクセス可能な検証アプリをSwiftUIで作ってみました。正直、自分がこの記事をみながらもう一度、AWSの設定とアプリから再現できるかは自信がありません。おそらく「ルービックキューブを適当に回しすぎた」トラウマかもしれません。でも、この記事を残さないと、再度「ルービックキューブを適当に回す」行為を繰り返す事になるので、ここに記す事とします。
A

macOS: 14.5 (23F79)
Xcode: Version 15.4 (15F31d)
$ swift --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0
$ aws --version
aws-cli/2.15.38 Python/3.11.8 Darwin/23.5.0 exe/x86_64 prompt/off
aws-sdk-swift: 0.46.0
0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2